From 2ef22c4a8ca49934723898ec05d16eaa95492e1b Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 6 Jun 2026 01:42:00 +0530 Subject: [PATCH] =?UTF-8?q?feat(api):=20OpenAPI=E2=86=92TS=20codegen=20+?= =?UTF-8?q?=20contract-drift=20gate=20(Wave=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1 contract-drift gate — docs/ci/01-CI-INTEGRATION-DESIGN.md. The UI↔API wire contract was hand-mirrored in src/api/types.ts; a backend field rename passed tsc+vitest here and broke prod at runtime (the login- break class). This derives the wire types from the api's OpenAPI snapshot so the same rename now fails tsc in `npm run gate` at PR time. - Add openapi-typescript devDep + `gen:api-types` script → src/api/generated.ts generated from a committed openapi.snapshot.json (byte-identical copy synced from the api repo; committed for CI determinism over fetching prod). npm `overrides` satisfies openapi-typescript@7's typescript@^5 peer with the repo's TS 6 — avoids --legacy-peer-deps (which drops @testing-library/dom). - Derive the highest-value wire shapes (the ones that broke before) from generated.ts and consume them in index.ts: WireAuthMe (fetchMe), WireResourceItem/ResourceListResponse (listResources/getResource/adaptResource), WireBillingState (fetchBilling/mapBillingState), WireDeployItem (listDeployments/getDeployment/adaptDeployment). TODO in types.ts lists the remaining hand-typed wire types + gate blind spots. - gen:api-types:check (scripts/check-api-types.mjs) fails CI if generated.ts is stale vs the snapshot; wired into ci.yml. prebuild regenerates it. - Exclude generated.ts from coverage (type-only codegen artifact). - Proven: renaming ResourceItem.storage_bytes in the snapshot + regenerating fails tsc at adaptResource (src/api/index.ts:669). gate green; patch cov 100%. Latent gaps surfaced (see report/types.ts TODO): DeployItem.failure has no exit_code on the wire though the UI reads it; BillingStateResponse lacks razorpay_configured in the spec though the UI consumes it; /api/v1/capabilities has no web consumer so it can't be tsc-gated. Cross-ref: api PR adds the oasdiff breaking-change gate (producer side). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 9 + README.md | 51 + openapi.snapshot.json | 10276 ++++++++++++++++++++++++++++++++++ package-lock.json | 249 +- package.json | 11 +- scripts/check-api-types.mjs | 58 + src/api/generated.ts | 10073 +++++++++++++++++++++++++++++++++ src/api/index.test.ts | 25 + src/api/index.ts | 205 +- src/api/types.ts | 65 + vite.config.ts | 12 + 11 files changed, 20931 insertions(+), 103 deletions(-) create mode 100644 openapi.snapshot.json create mode 100644 scripts/check-api-types.mjs create mode 100644 src/api/generated.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c93e5e4..6c96045 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,9 +40,18 @@ jobs: cache: 'npm' - run: npm ci + # Wave 1 contract-drift gate (docs/ci/01-CI-INTEGRATION-DESIGN.md): + # src/api/generated.ts is openapi-typescript output from the committed + # openapi.snapshot.json. Fail the PR if the committed types are stale vs + # the snapshot (mirrors how api/ gates openapi.snapshot.json). This keeps + # the UI's derived wire types faithful to the api contract, so a backend + # field rename fails `tsc` below instead of breaking prod at runtime. + - run: npm run gen:api-types:check # `npm run build` runs `tsc && vite build && node scripts/prerender.mjs`. # The prerender step is part of CI — a local `vite build` alone is NOT a # valid gate. `npm run gate` (package.json) runs this exact sequence. + # `prebuild` regenerates generated.ts, so the build always reflects the + # committed snapshot. - run: npm run build - run: npm test diff --git a/README.md b/README.md index b7bd90c..bd876a2 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,57 @@ src/ --- +## API contract types (OpenAPI codegen — contract-drift gate) + +The UI↔API wire contract is no longer hand-mirrored. It is **generated** from the +api's committed OpenAPI snapshot, so a backend field rename fails `tsc` at PR time +instead of breaking prod at runtime (the class that broke login). +Design ref: `docs/ci/01-CI-INTEGRATION-DESIGN.md` (Wave 1). + +``` +openapi.snapshot.json # committed copy, synced from the api repo (byte-identical) + │ npm run gen:api-types (openapi-typescript) + ▼ +src/api/generated.ts # GENERATED — do not hand-edit + │ Wire* aliases derive from components['schemas'][...] + ▼ +src/api/types.ts # WireAuthMe / WireResourceItem / WireBillingState / WireDeployItem + │ consumed by the adapters + ▼ +src/api/index.ts # fetchMe / listResources / fetchBilling / listDeployments ... +``` + +- **Regenerate:** `npm run gen:api-types` (also runs automatically in `prebuild`). +- **Up-to-date gate:** `npm run gen:api-types:check` fails if `generated.ts` is + stale vs the snapshot (runs in CI — `.github/workflows/ci.yml`). +- **Drift bite:** if the api renames/removes a field, regenerating `generated.ts` + changes the derived `Wire*` type and `tsc` (in `npm run gate`) fails at every + consumer site using the old field. + +### Syncing the snapshot from the api repo + +`openapi.snapshot.json` here is a **committed copy** of the api repo's +`openapi.snapshot.json` (chosen over fetching `https://api.instanode.dev/openapi.json` +at gen time so CI is deterministic and doesn't depend on prod being up). When the +api contract changes: + +```sh +cp ../api/openapi.snapshot.json ./openapi.snapshot.json # re-sync the copy +npm run gen:api-types # regenerate types +npm run gate # tsc will red at any UI site using a removed/renamed field +``` + +The api side gates the snapshot's faithfulness to its handlers (openapi-snapshot.yml) +and reds any breaking change at PR time (openapi-breaking.yml, oasdiff). See +`api/docs/OPENAPI-CONTRACT-GATES.md`. + +Conversion is incremental — the highest-value wire shapes (auth/me, resources, +billing, deployments) derive from `generated.ts` today; see the TODO at the bottom +of `src/api/types.ts` for the remaining hand-typed wire types and known gate blind +spots (e.g. `/api/v1/capabilities` has no web consumer). + +--- + ## Auth Flow 1. User pastes a PAT or completes the email magic-link flow on `LoginPage`. diff --git a/openapi.snapshot.json b/openapi.snapshot.json new file mode 100644 index 0000000..a3627d9 --- /dev/null +++ b/openapi.snapshot.json @@ -0,0 +1,10276 @@ +{ + "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" + }, + "items": { + "description": "All anonymous resources that this JWT would attach to the new team if /claim were posted. Canonical envelope field — matches /api/v1/resources, /api/v1/deployments, /api/v1/audit, and every other list endpoint on the platform.", + "items": { + "$ref": "#/components/schemas/ResourceItem" + }, + "type": "array" + }, + "ok": { + "type": "boolean" + }, + "resources": { + "deprecated": true, + "description": "DEPRECATED — legacy alias of items. Kept populated for back-compat with the dashboard and existing curl recipes; new clients should read items. B5-P1-3 (BugBash 2026-05-20).", + "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" + }, + "redeploy": { + "default": false, + "description": "When true with a matching 'name', replace the existing deployment in place (same app_id + URL, same provider_id) instead of minting a fresh one. The platform looks up the team's most-recent non-terminal deployment whose env_vars._name matches the supplied 'name' (scoped to the resolved 'env'), then routes through the same compute path as POST /deploy/:id/redeploy. Closes the agent-UX gap (2026-05-30): multiple /deploy/new calls for the same logical app used to fan out into N distinct URLs because there was no way to upsert by name. Truthy values: 'true', '1', 'yes' (case-insensitive); anything else is false. Errors: 404 no_existing_deployment_to_redeploy when no live row matches (note: an empty 'name' is rejected upstream by the standard name_required check, before this flag is even consulted) (omit 'redeploy' to create a new deployment, or call GET /api/v1/deployments first to discover the id); 409 not_ready when the matching row exists but has no provider_id yet (initial build still running). Default false: leaving the field absent keeps the legacy fan-out behaviour.", + "type": "boolean" + }, + "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 10 MB). Over the cap returns 413 tarball_too_large with an agent_action — slim the upload (exclude node_modules/.git/build output) or deploy a prebuilt image instead of uploading source. 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" + }, + "redeployed": { + "description": "Mirror of the top-level 'redeployed' flag — included inside item so a client that reads only item still sees the in-place-vs-fresh branch indicator. True when this row was reused via POST /deploy/new redeploy=true, false on the fresh-deploy path.", + "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" + }, + "redeployed": { + "description": "True when this response served an in-place redeploy (POST /deploy/new redeploy=true matched an existing deployment), false on the fresh-deploy path. Always present so agents have a single response shape across both branches.", + "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" + }, + "resource_count_limit": { + "additionalProperties": { + "type": "integer" + }, + "description": "Task #55: per-service max number of active resources a team may hold. Keys: postgres, vector, redis, mongodb, storage, queue (webhook is request-capped, not count-capped). -1 = unlimited. Enforcement is flag-gated (RESOURCE_COUNT_CAPS_ENABLED) but the cap is always advertised so an agent can plan around it.", + "type": "object" + }, + "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, and (Task #55) on postgres/redis/mongodb as the active-resource count alongside bytes.", + "type": "integer" + }, + "count_limit": { + "description": "Task #55: per-tier resource-COUNT cap for the byte-metered storage services (postgres/redis/mongodb), where the limit field is unused. -1 = unlimited. Enforcement is flag-gated (RESOURCE_COUNT_CAPS_ENABLED) but the cap is always advertised.", + "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 built for AI coding agents (Claude Code, Cursor, MCP tool-use) and humans alike. Provision real Postgres + pgvector + Redis + MongoDB + NATS JetStream queues + S3-compatible object storage + webhook receivers — AND deploy your app on top of them — each with a single HTTP call. No account, no Docker, no setup. A free anonymous tier (24h TTL) lets an agent claim infrastructure the moment it hits a limit, with no signup. Also available as an MCP server, language SDKs, and a CLI for agent tool-use. The unit of value is the whole bundle: everything an agent needs to ship a working app, claimed and provisioned in one flow. Keywords: AI agent infrastructure, MCP, Postgres, pgvector / vector database, Redis cache, MongoDB, NATS queue, S3 object storage, webhooks, app deployment, free tier, single HTTP call, no signup.\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.dev — zero-friction dev infrastructure for AI agents", + "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 on the same Razorpay subscription (upgrades only — downgrades are support-assisted). Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. The Team plan is NOT yet available for self-serve plan changes — target_plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev).", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "target_plan": { + "description": "Target tier. The Team plan is not yet self-serve purchasable (contact sales) — target_plan=team returns 400 tier_not_yet_available.", + "enum": [ + "hobby", + "hobby_plus", + "pro" + ], + "type": "string" + } + }, + "required": [ + "target_plan" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Plan change accepted by Razorpay" + }, + "400": { + "description": "Invalid plan, downgrade_not_self_serve, or tier_not_yet_available (target_plan=team — the Team plan is not yet self-serve purchasable; contact sales)" + }, + "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, or pro) 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. The Team plan ($199, finite high-capacity limits — not unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). Capacity beyond the Team caps is Enterprise (contact sales). 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": { + "description": "Self-serve purchasable plans. The Team plan is NOT yet available for self-serve checkout (contact sales: support@instanode.dev) — plan=team returns 400 tier_not_yet_available.", + "enum": [ + "hobby", + "hobby_plus", + "pro" + ], + "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, already_on_plan (the team already holds the requested tier or higher), or tier_not_yet_available (plan=team — the Team plan is not yet self-serve purchasable; contact sales)" + }, + "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": "claim_required — anonymous tier. The remediation is a FREE claim, not a paid upgrade; upgrade_url points at https://instanode.dev/claim." + }, + "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": "claim_required — anonymous tier (remediation is a free claim, not a paid upgrade)" + }, + "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. As of the 2026-06-05 strict-margin redesign Team has finite limits too, so Team callers can also approach a wall (next step above Team is Enterprise/contact-sales). 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/exchange": { + "post": { + "description": "Final leg of the AUTH-004 cross-origin sign-in handshake. The /auth/email/callback and /auth/github/callback handlers set a short-lived HttpOnly auth_exchange_cookie and 302 to https://instanode.dev/login/callback?signed_in=1. The dashboard then makes a credentials:include POST to this endpoint. CORS contract: response MUST include Access-Control-Allow-Origin: https://instanode.dev AND Access-Control-Allow-Credentials: true — the browser blocks the read otherwise. Request MUST be a CORS-simple POST (no custom headers like Accept: application/json), since adding one forces a preflight that PreflightAllowlist may reject. Returns 200 + the bearer JWT (24h, HS256, aud=https://api.instanode.dev) on success. The 2026-05-29 to 2026-05-30 prod-login outage chained three failures along this exact endpoint — documenting it here so any future regression is catchable by the cross-stack contract gate (api PR #202).", + "requestBody": { + "description": "No body. The bridge cookie travels in the Cookie header via credentials:include.", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "token": { + "description": "Session JWT — store in localStorage and send as Authorization: Bearer for /api/v1/* calls", + "type": "string" + } + }, + "required": [ + "ok", + "token" + ], + "type": "object" + } + } + }, + "description": "Cookie verified; JWT minted" + }, + "400": { + "description": "Bridge cookie missing / expired (canonical envelope with error code cookie_missing_or_expired)" + }, + "401": { + "description": "Cookie present but signature invalid or aud mismatch" + }, + "503": { + "description": "JWT signing failed (downstream)" + } + }, + "summary": "Exchange the AUTH-004 bridge cookie for a session JWT" + } + }, + "/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" + } + }, + "/deploy/{id}/wake": { + "post": { + "description": "Scale-to-zero (Task #54). Scales an idle, descheduled app back to one replica and clears its sleeping state. The app becomes reachable once its pod is Ready (a one-time cold start — a request that races the wake gets the ingress's upstream-down response until the pod is up). Idempotent: waking an already-awake app just refreshes its last-activity marker so the idle-scaler won't immediately re-deschedule it. Returns 501 when scale-to-zero is not enabled on the platform (the default). Cross-tenant requests return 404.", + "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 woken" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found (or owned by another team)" + }, + "501": { + "description": "scale_to_zero_disabled — scale-to-zero is not enabled on this platform (default)." + }, + "503": { + "description": "wake_failed — transient failure scaling the app; retry." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Wake a scaled-to-zero (sleeping) deployment" + } + }, + "/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" + } + ], + "tags": [ + { + "description": "Real Postgres connection strings via POST /db/new — encrypted at rest, per-token isolation, instant.", + "name": "database" + }, + { + "description": "pgvector-enabled Postgres via POST /vector/new — embedding stores with HNSW + IVFFlat for AI/RAG workloads.", + "name": "vector" + }, + { + "description": "Real Redis connection strings via POST /cache/new — ACL namespace isolation.", + "name": "cache" + }, + { + "description": "Real MongoDB connection strings via POST /nosql/new — per-token database scoping.", + "name": "nosql" + }, + { + "description": "NATS JetStream URLs via POST /queue/new — per-account subject isolation.", + "name": "queue" + }, + { + "description": "S3-compatible object storage via POST /storage/new plus broker-mode signed URLs via POST /storage/{token}/presign.", + "name": "storage" + }, + { + "description": "Public webhook receiver URLs via POST /webhook/new — captures any HTTP method payload.", + "name": "webhook" + }, + { + "description": "The deployment wedge: ship an app via POST /deploy/new (single app) or POST /stacks/new (multi-service) — Docker build to public HTTPS URL with TLS, no Dockerfile-on-disk required.", + "name": "deploy" + }, + { + "description": "Magic-link, GitHub OAuth, and CLI device-flow login — used to claim a free anonymous resource bundle.", + "name": "auth" + } + ] +} diff --git a/package-lock.json b/package-lock.json index aa31a52..e96b51c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "^4.1.8", "jsdom": "^29.1.1", + "openapi-typescript": "^7.13.0", "size-limit": "^12.1.0", "typescript": "^6.0.3", "vite": "^8.0.16", @@ -90,7 +91,6 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -530,6 +530,52 @@ "node": ">=18" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.15", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", + "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -1231,6 +1277,16 @@ "node": ">= 14" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1255,6 +1311,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -1323,6 +1386,13 @@ } } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/bare-events": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", @@ -1449,6 +1519,16 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -1479,6 +1559,13 @@ "node": ">=18" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/chromium-bidi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-8.0.0.tgz", @@ -1597,6 +1684,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz", @@ -1915,6 +2009,13 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -2096,6 +2197,19 @@ "node": ">= 14" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -2162,13 +2276,35 @@ "node": ">=8" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } }, "node_modules/jsdom": { "version": "29.1.1", @@ -2211,6 +2347,13 @@ } } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2551,6 +2694,19 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -2624,6 +2780,40 @@ "wrappy": "1" } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -2658,6 +2848,24 @@ "node": ">= 14" } }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -2736,6 +2944,16 @@ "node": ">=18" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -3331,6 +3549,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-query-selector": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", @@ -3369,6 +3600,13 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -3686,6 +3924,13 @@ "node": ">=10" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 18e65ec..df091a9 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,11 @@ "type": "module", "scripts": { "fetch-content": "node scripts/fetch-content.mjs", + "gen:api-types": "openapi-typescript ./openapi.snapshot.json -o ./src/api/generated.ts", + "gen:api-types:check": "node scripts/check-api-types.mjs", "predev": "node scripts/fetch-content.mjs", "dev": "vite", - "prebuild": "node scripts/fetch-content.mjs", + "prebuild": "node scripts/fetch-content.mjs && npm run gen:api-types", "build": "tsc && vite build && node scripts/prerender.mjs", "preview": "vite preview", "test": "vitest run", @@ -39,11 +41,18 @@ "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "^4.1.8", "jsdom": "^29.1.1", + "openapi-typescript": "^7.13.0", "size-limit": "^12.1.0", "typescript": "^6.0.3", "vite": "^8.0.16", "vitest": "^4.1.7" }, + "overridesComment": "openapi-typescript@7 peer-requires typescript@^5; this repo is on TS 6 and the codegen works fine with it. The override below satisfies that peer with the repo's own typescript so plain `npm ci` resolves WITHOUT --legacy-peer-deps (which would otherwise drop the auto-installed @testing-library/dom peer and break the test type surface). Scoped to the openapi-typescript subtree only.", + "overrides": { + "openapi-typescript": { + "typescript": "$typescript" + } + }, "size-limit": [ { "path": "dist/assets/index-*.js", diff --git a/scripts/check-api-types.mjs b/scripts/check-api-types.mjs new file mode 100644 index 0000000..e6be1d1 --- /dev/null +++ b/scripts/check-api-types.mjs @@ -0,0 +1,58 @@ +/* check-api-types.mjs — up-to-date gate for the generated API types. + * + * Wave 1 contract-drift gate (docs/ci/01-CI-INTEGRATION-DESIGN.md). Mirrors how + * the api repo gates openapi.snapshot.json: regenerate src/api/generated.ts from + * the committed openapi.snapshot.json and FAIL if the committed file differs. + * + * Why: src/api/generated.ts is the openapi-typescript output that the wire types + * in src/api/types.ts derive from. If someone updates openapi.snapshot.json (the + * api contract) but forgets to regenerate generated.ts, the UI's derived types + * would silently lag the contract — defeating the whole gate. This check makes + * that lag a CI failure with the exact fix command. + * + * The api->web snapshot sync itself is a committed copy + * (instanode-web/openapi.snapshot.json, synced from the api repo's + * openapi.snapshot.json). Cross-repo verification at CI time is intentionally + * NOT attempted here (CI must not depend on the api repo being checked out or on + * prod being up) — the copy is committed for determinism and re-synced when the + * api contract changes (see docs note). This check only guarantees generated.ts + * is faithful to whatever snapshot copy is committed. + */ +import { execFileSync } from 'node:child_process' +import { readFileSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const SNAPSHOT = './openapi.snapshot.json' +const COMMITTED = './src/api/generated.ts' + +const tmp = mkdtempSync(join(tmpdir(), 'gen-api-types-')) +const regenerated = join(tmp, 'generated.ts') + +try { + // Regenerate into a temp file using the same tool/version as `gen:api-types`. + execFileSync( + 'npx', + ['openapi-typescript', SNAPSHOT, '-o', regenerated], + { stdio: ['ignore', 'ignore', 'inherit'] }, + ) + + const committed = readFileSync(COMMITTED, 'utf8') + const fresh = readFileSync(regenerated, 'utf8') + + if (committed !== fresh) { + console.error( + '\n✗ src/api/generated.ts is OUT OF DATE with openapi.snapshot.json.\n' + + ' The committed generated API types do not match what `openapi-typescript`\n' + + ' produces from the current snapshot. Run:\n\n' + + ' npm run gen:api-types\n\n' + + ' and commit the updated src/api/generated.ts in this PR.\n' + + ' (Wave 1 contract-drift gate — docs/ci/01-CI-INTEGRATION-DESIGN.md.)\n', + ) + process.exit(1) + } + + console.log('✓ src/api/generated.ts is up to date with openapi.snapshot.json.') +} finally { + rmSync(tmp, { recursive: true, force: true }) +} diff --git a/src/api/generated.ts b/src/api/generated.ts new file mode 100644 index 0000000..18a0b54 --- /dev/null +++ b/src/api/generated.ts @@ -0,0 +1,10073 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/.well-known/oauth-protected-resource": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * OAuth 2.0 Protected Resource Metadata (RFC 9728) + * @description Discovery document used by MCP clients to obtain authorization metadata. Public, no auth required. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Metadata document */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OAuthProtectedResourceMetadata"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Customer-facing audit log export (W7-C compliance) + * @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. + */ + get: { + parameters: { + query?: { + /** @description Page size. Default 50, max 200. The endpoint returns at most this many rows per call; use ?before= to fetch older rows. */ + limit?: number; + /** @description Cursor — only return rows with created_at strictly older than this RFC3339 timestamp. Pass the previous response's next_cursor field here. */ + before?: string; + /** @description Exact kind match (e.g. 'resource.read', 'subscription.upgraded'). Admin.* kinds always return zero rows even when explicitly requested. */ + kind?: 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. */ + since?: string; + /** @description Exclusive upper bound (RFC3339). */ + until?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Audit event list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items?: components["schemas"]["AuditExportItem"][]; + /** @description Plan-derived hard floor. -1 means unlimited (growth/team). */ + lookback_days?: number; + /** + * Format: date-time + * @description Pass to ?before= on the next call. Null when this is the last page (the page wasn't full). + */ + next_cursor?: string | null; + ok?: boolean; + /** @description The caller's resolved plan tier at request time. */ + tier?: string; + /** @description Number of items in this page. */ + total_returned?: number; + }; + }; + }; + /** @description Invalid query parameter (e.g. malformed ?before / ?since / ?until — must be RFC3339). */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Plan does not include audit export. Anonymous/free → upgrade required. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/audit.csv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Customer-facing audit log export — CSV stream (W7-C compliance) + * @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. + */ + get: { + parameters: { + query?: { + /** @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. */ + limit?: number; + before?: string; + kind?: string; + since?: string; + until?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Audit event CSV. Header row is always emitted. Content-Disposition: attachment; filename="audit.csv". */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": string; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Plan does not include audit export. Anonymous/free → upgrade required. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/api-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Personal Access Tokens for the team + * @description Returns metadata only — plaintext keys are never echoed back. Each item has id, name, scopes, created_at, last_used_at (nullable), and revoked. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description API key list (metadata only) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items?: { + /** Format: date-time */ + created_at?: string; + /** Format: uuid */ + id?: string; + /** Format: date-time */ + last_used_at?: string | null; + name?: string; + revoked?: boolean; + scopes?: string[]; + }[]; + ok?: boolean; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + /** + * Mint a Personal Access Token (long-lived bearer for agents/CI) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Human-readable label, e.g. 'laptop' or 'github-actions' */ + name: string; + scopes?: ("read" | "write" | "admin")[]; + }; + }; + }; + responses: { + /** @description Key created — plaintext returned exactly once */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: date-time */ + created_at?: string; + /** Format: uuid */ + id?: string; + /** @description Plaintext bearer token — copy now, never shown again */ + key?: string; + name?: string; + note?: string; + ok?: boolean; + scopes?: string[]; + }; + }; + }; + /** @description Body invalid, missing name, name too long, or invalid scope */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description PAT-creating-a-PAT is forbidden — use a user session */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Token generation or DB write failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/api-keys/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke a Personal Access Token + * @description Soft-deletes the key (sets revoked_at = now()). Tokens that have been revoked fail subsequent auth checks immediately. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revoked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uuid */ + id?: string; + ok?: boolean; + }; + }; + }; + /** @description Path id is not a UUID */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Key not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/billing": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Aggregated billing state for the authenticated team + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Aggregated billing state */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BillingStateResponse"]; + }; + }; + /** @description Missing or invalid session token */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Team not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/billing/change-plan": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Switch the team's subscription to a different tier + * @description Hobby ↔ Hobby Plus ↔ Pro on the same Razorpay subscription (upgrades only — downgrades are support-assisted). Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. The Team plan is NOT yet available for self-serve plan changes — target_plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description Target tier. The Team plan is not yet self-serve purchasable (contact sales) — target_plan=team returns 400 tier_not_yet_available. + * @enum {string} + */ + target_plan: "hobby" | "hobby_plus" | "pro"; + }; + }; + }; + responses: { + /** @description Plan change accepted by Razorpay */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid plan, downgrade_not_self_serve, or tier_not_yet_available (target_plan=team — the Team plan is not yet self-serve purchasable; contact sales) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid session token */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No active subscription */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Razorpay not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/billing/checkout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a Razorpay subscription and return its hosted-page URL + * @description Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, or pro) 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. The Team plan ($199, finite high-capacity limits — not unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). Capacity beyond the Team caps is Enterprise (contact sales). 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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description Self-serve purchasable plans. The Team plan is NOT yet available for self-serve checkout (contact sales: support@instanode.dev) — plan=team returns 400 tier_not_yet_available. + * @enum {string} + */ + plan: "hobby" | "hobby_plus" | "pro"; + /** + * @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. + * @default monthly + * @enum {string} + */ + plan_frequency?: "monthly" | "yearly"; + }; + }; + }; + responses: { + /** @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. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + /** @description Present and true only when an existing still-payable subscription was returned instead of minting a new one. */ + reused?: boolean; + /** Format: uri */ + short_url?: string; + subscription_id?: string; + /** + * @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 {string} + */ + traffic_env?: "production" | "test"; + }; + }; + }; + /** @description Invalid plan, invalid plan_frequency, already_on_plan (the team already holds the requested tier or higher), or tier_not_yet_available (plan=team — the Team plan is not yet self-serve purchasable; contact sales) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid session token */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Razorpay rejected the create-subscription call */ + 502: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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) */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/billing/invoices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the team's invoices + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invoice list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + invoices?: { + /** @description Amount in paise (INR×100) */ + amount?: number; + currency?: string; + id?: string; + status?: string; + }[]; + ok?: boolean; + }; + }; + }; + /** @description Missing or invalid session token */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Razorpay not configured on this environment */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/billing/promotion/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Validate a promotion code against a target plan + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description Promotion code (case-insensitive) + * @example LAUNCH50 + */ + code: string; + /** + * @description Plan tier the discount must apply to + * @enum {string} + */ + plan: "hobby" | "hobby_plus" | "pro" | "team"; + }; + }; + }; + responses: { + /** @description Either a valid discount (ok:true) or a typed rejection (ok:false). The dashboard branches on the ok field, not the status code. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Empty code, missing plan, or malformed JSON body */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid session token */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Team exceeded 30 validations per hour. Wait for the next hourly bucket. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/billing/update-payment": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Return a Razorpay hosted-page URL the user can use to update their card on file */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Hosted page URL */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + /** Format: uri */ + short_url?: string; + }; + }; + }; + /** @description Missing or invalid session token */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No active subscription */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Razorpay not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/billing/usage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Aggregated usage metrics for the authenticated team (cached) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Aggregated usage payload */ + 200: { + headers: { + /** @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. */ + "Cache-Control"?: string; + [name: string]: unknown; + }; + content: { + /** + * @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 + * } + * } + * } + */ + "application/json": components["schemas"]["BillingUsageResponse"]; + }; + }; + /** @description Missing or invalid session token. Response includes agent_action pointing the user at https://instanode.dev/login. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Failed to compute usage (transient DB error). Retry with backoff. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/capabilities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Tier capabilities matrix (public) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Capability matrix */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CapabilitiesResponse"]; + }; + }; + /** @description plans.yaml registry failed to load */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deployments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List all deployments owned by the caller's team */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployment list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items?: components["schemas"]["DeployItem"][]; + ok?: boolean; + total?: number; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deployments/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a deployment by id (alias of GET /deploy/{id}) */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployment record */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeployResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not your deployment */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + /** + * Tear down + delete a deployment (two-step for paid tiers; immediate for anon/free or with bypass header) + * @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. + */ + delete: { + parameters: { + query?: never; + header?: { + /** @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. */ + "X-Skip-Email-Confirmation"?: "yes"; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Immediate destruction path (anonymous/free tier OR header bypass): deployment torn down synchronously. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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. */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not your deployment */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description deletion_already_pending — a pending email is already in flight for this resource. Cancel it first via DELETE /confirm-deletion, then retry. */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description deletion_email_disabled — paid team has no verified owner email on file. */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description email_send_failed — transient email-backend failure; safe to retry. */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + /** + * Update access-control fields (private + allowed_ips) in place + * @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). + */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description REPLACE the allow-list with this exact set of IPs/CIDRs. Max 32 entries; each must be a valid IP literal or CIDR. */ + allowed_ips?: string[]; + /** @description Flip the deploy public ↔ private. When false, the allow-list is cleared regardless of allowed_ips in the same body. */ + private?: boolean; + }; + }; + }; + responses: { + /** @description Access control updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeployResponse"]; + }; + }; + /** @description Bad request — missing_fields (empty body), private_deploy_requires_allowed_ips, invalid_allowed_ip, too_many_allowed_ips, or invalid_body */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description private_deploy_requires_pro — hobby/anonymous/free trying to flip a deploy private. agent_action points to https://instanode.dev/pricing. */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not your deployment */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description compute_update_failed (ingress patch failed) or update_failed (DB write failed) */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + trace?: never; + }; + "/api/v1/deployments/{id}/confirm-deletion": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Confirm a pending deletion (paid tiers, Wave FIX-I) + * @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. + */ + post: { + parameters: { + query: { + /** @description Plaintext confirmation token from the email link (starts with 'del_'). Stored only as sha256 hash server-side. */ + token: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deletion confirmed. Body: { ok, id, resource_type, deletion_status='confirmed', freed_at, agent_action, note }. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description missing_token — query parameter omitted. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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. */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description deletion_lookup_failed / deletion_mark_failed / deletion_email_disabled — transient DB failure or email backend not wired. */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + /** + * Cancel a pending deletion (paid tiers, Wave FIX-I) + * @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. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cancellation confirmed. Body: { ok, id, resource_type, deletion_status='cancelled', agent_action, note }. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not your deployment */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No pending deletion to cancel for this resource. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Pending row is already resolved (confirmed/cancelled/expired). */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deployments/{id}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List deployment_events rows (failure timeline) for a deployment + * @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. + * + * Each 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). + * + * Read-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. + */ + get: { + parameters: { + query?: { + /** @description Max rows to return. Default 50, hard cap 200. Values above 200 are silently clamped; values < 1 fall back to the default. */ + limit?: number; + }; + header?: never; + path: { + /** @description Deployment app_id (the short public token returned by POST /deploy/new, same value GET /api/v1/deployments/{id} accepts). */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Events list (may be empty for a healthy / never-failed deployment). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @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 + * } + */ + "application/json": components["schemas"]["DeploymentEventsResponse"]; + }; + }; + /** @description invalid_id — empty id in the URL. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unauthorized — missing or invalid bearer token. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description fetch_failed / events_query_failed — transient DB failure during the deployment lookup or the events list. Safe to retry. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deployments/{id}/github": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the current GitHub connection for a deployment + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Connection status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + connected?: boolean; + connection?: components["schemas"]["GitHubConnection"] | null; + ok?: boolean; + /** + * Format: uri + * @description Present only when connected=true. + */ + webhook_url?: string; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not your deployment */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Deployment not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + /** + * Connect a deployment to a GitHub repository for auto-deploy + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Deployment app_id (short slug, e.g. '6fffcc21'). */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description Branch to watch. Defaults to 'main'. Pushes to other branches are ignored at receive time. + * @example main + */ + branch?: string; + /** + * Format: int64 + * @description Optional GitHub App installation id. Reserved for a future private-repo flow; today plain webhooks are used and this field can be omitted. + */ + installation_id?: number; + /** + * @description GitHub repository in 'owner/repo' form, e.g. 'octocat/hello-world'. + * @example octocat/hello-world + */ + repo: string; + }; + }; + }; + responses: { + /** @description Connection created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + connection?: components["schemas"]["GitHubConnection"]; + note?: string; + ok?: boolean; + /** @description Plaintext HMAC signing key. Paste into GitHub → Settings → Webhooks → Secret. Returned ONCE — not surfaced again. */ + webhook_secret?: string; + /** + * Format: uri + * @description Paste into GitHub → Settings → Webhooks → Payload URL. + */ + webhook_url?: string; + }; + }; + }; + /** @description Bad request — invalid_repo (not owner/repo form), invalid_branch (>250 chars), or invalid_body */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description github_requires_paid_tier — anonymous / free trying to connect. agent_action points to https://instanode.dev/pricing. */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not your deployment */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Deployment not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description already_connected — deployment already has a GitHub connection. DELETE first to reconnect. */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description encryption_unavailable / encryption_failed / create_failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + /** + * Disconnect a deployment from GitHub auto-deploy + * @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. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Connection removed (or no-op when none existed) */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not your deployment */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Deployment not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description delete_failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deployments/{id}/make-permanent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Opt a deployment out of the auto-24h TTL + * @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'. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Deployment id (UUID or short app_id slug). */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployment kept permanently */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeployResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description claim_required — anonymous tier. The remediation is a FREE claim, not a paid upgrade; upgrade_url points at https://instanode.dev/claim. */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found (or owned by another team) */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deployments/{id}/ttl": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set a custom TTL for a deployment + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Number of hours from now until the deploy auto-expires. 1..8760 (1 hour to 1 year). */ + hours: number; + }; + }; + }; + responses: { + /** @description TTL updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeployResponse"]; + }; + }; + /** @description invalid_hours — outside 1..8760 */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description claim_required — anonymous tier (remediation is a free claim, not a paid upgrade) */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/experiments/converted": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * A/B-experiment conversion sink + * @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). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Short action identifier (e.g. 'checkout_started'). Truncated to 64 chars. */ + action?: string; + /** @description Registered experiment name (e.g. 'upgrade_button'). */ + experiment: string; + /** @description The variant the client rendered. Must be a registered variant of the experiment AND match the server's bucket for this team. */ + variant: string; + }; + }; + }; + responses: { + /** @description Conversion recorded */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + }; + }; + }; + /** @description Invalid body, unknown_experiment, invalid_variant, or variant_mismatch */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/families/bulk-twin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk env-twin every parent resource in source_env (Pro+) + * @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. + * + * Returns 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. + * + * Concurrency: 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. + * + * Quota 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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @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. */ + resource_types?: ("postgres" | "redis" | "mongodb")[]; + /** @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. */ + source_env: string; + /** @description Env to copy TO (e.g. "staging"). Must differ from source_env. Same charset rule as source_env. */ + target_env: string; + }; + }; + }; + responses: { + /** @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. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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. */ + 207: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description missing_source_env / missing_target_env / invalid_source_env / invalid_target_env / same_env (source and target are identical). */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized — Bearer token required. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description upgrade_required — team is on hobby/free; response carries agent_action + upgrade_url. Multi-env workflows are a Pro+ differentiator. */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description team_lookup_failed / list_failed — transient DB error; retry with backoff. */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/incidents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Current and recent incidents (public) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Incident list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IncidentsResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/invitations/{token}/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Accept an invitation by token (no auth required — token IS the auth) + * @description Public endpoint. The token is single-use and ties the accepting user's session to the invited team and role. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Accepted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + role?: string; + /** Format: uuid */ + team_id?: string; + }; + }; + }; + /** @description Token not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Token already used or expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List all resources for the authenticated team */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Resource list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResourceListResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/families": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List resource families for the authenticated team + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Family list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + families?: { + /** + * Format: uuid + * @description Stable family identifier — the row's own id when it is its own root. + */ + family_root_id?: string; + members_per_env?: { + [key: string]: { + env?: string; + /** Format: uuid */ + id?: string; + /** @description true when this row is the family root (parent_resource_id IS NULL). */ + is_root?: boolean; + name?: string; + resource_type?: string; + status?: string; + tier?: string; + /** Format: uuid */ + token?: string; + }; + }; + /** @description postgres | redis | mongodb | webhook | queue | storage */ + resource_type?: string; + }[]; + ok?: boolean; + total?: number; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a specific resource */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Resource detail */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden — resource belongs to another team */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + /** Delete a resource */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Resource deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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 }. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/backup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Trigger an ad-hoc Postgres backup + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource token UUID — must be a postgres resource owned by the authenticated team. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Backup queued */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uuid */ + backup_id?: string; + message?: string; + ok?: boolean; + /** Format: date-time */ + started_at?: string; + /** @enum {string} */ + status?: "pending"; + }; + }; + }; + /** @description invalid_id (resource UUID malformed) or unsupported_resource_type (resource is not postgres) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized — session token required */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description upgrade_required — anonymous/free tier cannot back up; response carries agent_action + upgrade_url */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden — caller doesn't own the resource */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description not_found — resource doesn't exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description backup_create_failed — transient DB error; retry with backoff */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/backups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List backups for a resource + * @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. + */ + get: { + parameters: { + query?: { + /** @description Max rows to return. Capped at 200. */ + limit?: number; + /** @description Cursor — only rows with created_at < before are returned. Pass the oldest item's created_at to paginate backwards. */ + before?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Backup list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items?: { + /** Format: uuid */ + backup_id?: string; + /** @enum {string} */ + backup_kind?: "scheduled" | "manual"; + /** Format: date-time */ + created_at?: string; + /** @description Short human-readable failure reason. Only set when status='failed'. */ + error_summary?: string | null; + /** Format: date-time */ + finished_at?: string | null; + /** @description Size of the pg_dump artifact in bytes. NULL until the worker writes the terminal row. */ + size_bytes?: number | null; + /** Format: date-time */ + started_at?: string; + /** @enum {string} */ + status?: "pending" | "running" | "ok" | "failed"; + /** @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. */ + tier_at_backup?: string | null; + }[]; + ok?: boolean; + /** @description Total backups for this resource (not just the current page). Used by the dashboard to render pagination affordances. */ + total?: number; + }; + }; + }; + /** @description invalid_id or invalid_cursor (?before is not RFC3339) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden — caller doesn't own the resource */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description not_found — resource doesn't exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read the decrypted connection_url for a resource + * @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). + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Decrypted connection URL */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + connection_url?: string; + env?: string; + /** Format: uuid */ + id?: string; + ok?: boolean; + resource_type?: string; + /** Format: uuid */ + token?: string; + }; + }; + }; + /** @description Resource has no connection_url */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Resource not found (or owned by another team) */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Encryption key invalid or decryption failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/family": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the env-twin family for a resource + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Any member of the family — root or child. The handler resolves the root by walking parent_resource_id. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Family payload */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uuid */ + family_root_id?: string; + members?: { + /** Format: date-time */ + created_at?: string; + env?: string; + /** Format: uuid */ + id?: string; + is_root?: boolean; + name?: string; + /** @description Empty for the root; otherwise the root's id. */ + parent_resource_id?: string; + resource_type?: string; + status?: string; + tier?: string; + /** Format: uuid */ + token?: string; + }[]; + ok?: boolean; + total?: number; + }; + }; + }; + /** @description Resource ID is not a valid UUID */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Cross-team — caller does not own this resource */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Per-resource time-series metrics (p50/p95/p99 latency, connections, storage, error rate) + * @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). + */ + get: { + parameters: { + query?: { + /** @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. */ + window?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Metrics fetched */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @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 {string} + */ + data_source?: "stub" | "newrelic" | "resource_metrics"; + /** @description All arrays have length samples_count. Empty during the stub window means awaiting-first-probe-sample, not backend-down. */ + metrics?: { + connections_active?: number[]; + error_rate_pct?: number[]; + latency_p50_ms?: number[]; + latency_p95_ms?: number[]; + latency_p99_ms?: number[]; + storage_bytes?: number[]; + }; + ok?: boolean; + /** Format: uuid */ + resource_id?: string; + /** @description postgres | redis | mongodb | webhook | queue | storage */ + resource_type?: string; + /** @description Fixed at 60. Tier ceilings change window, not bucket width. */ + sample_interval_seconds?: number; + /** @description Equals window_seconds / sample_interval_seconds. Capped at 10080 (7d @ 1min). */ + samples_count?: number; + /** + * Format: int64 + * @description Resolved window in seconds (post-default, post-cap-rejection). + */ + window_seconds?: number; + }; + }; + }; + /** @description invalid_id — :id is not a valid UUID — OR invalid_window — window param unparseable, non-positive, or > 7d hard maximum */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized — session token required */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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. */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden — caller's team doesn't own the resource */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description not_found — resource doesn't exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description fetch_failed — DB lookup failed (transient infra error) */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/pause": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Pause a resource (suspend without deletion) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Resource paused */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uuid */ + id?: string; + message?: string; + ok?: boolean; + /** @enum {string} */ + status?: "paused"; + /** Format: uuid */ + token?: string; + }; + }; + }; + /** @description invalid_id — :id is not a valid UUID */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized — session token required */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description upgrade_required — pause/resume requires Pro+. Body: { error: 'upgrade_required', upgrade_url, agent_action }. */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden — caller doesn't own the resource */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description not_found — resource doesn't exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description already_paused — the resource is already paused (idempotent error) */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description provider_failed — the provider-side revoke failed; the DB row is unchanged */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/provision-twin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Provision an env-twin of an existing resource (Pro+) + * @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). + * + * Email-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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Token of the source resource (root or any sibling — the handler resolves the family root). */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Optional. Pass an already-approved approval row id to run the twin immediately (skips the email-link wait). */ + approval_id?: string; + /** @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. */ + env: string; + /** @description Optional human-readable label (max 120 chars). Falls back to the source's name when omitted. */ + name?: string; + }; + }; + }; + responses: { + /** @description Twin provisioned — body carries connection_url + family_root_id (same shape as POST /db/new etc.) */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Pending approval — non-dev target env, no approval_id supplied. Body: { status: 'pending_approval', approval_id, expires_at, agent_action, ... }. */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description invalid_id / missing_env / invalid_env / unsupported_for_twin (source isn't postgres/redis/mongodb), or approval_id mismatched */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description upgrade_required — team is on hobby/free; response carries agent_action + upgrade_url */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description forbidden — caller does not own the source resource */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Source resource not found, or approval_id does not match any row for this team */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description twin_exists — family already has a row in the requested env, OR approval_id is not in status='approved' */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description approval_id is past its 24h expiry window */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description provision_failed — downstream provisioner errored; resource row was soft-deleted */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Restore a Postgres resource from a backup (Pro+) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Resource token UUID — target of the restore. Must be owned by the authenticated team. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * Format: uuid + * @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. + */ + backup_id: string; + }; + }; + }; + responses: { + /** @description Restore queued */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message?: string; + ok?: boolean; + /** Format: uuid */ + restore_id?: string; + /** Format: date-time */ + started_at?: string; + /** @enum {string} */ + status?: "pending"; + }; + }; + }; + /** @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) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized — session token required AND must carry a user identity (PAT-only sessions are rejected) */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description upgrade_required — restore is Pro+. Response carries agent_action + upgrade_url to https://instanode.dev/pricing. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden — caller doesn't own the resource */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description not_found (resource doesn't exist) OR backup_not_found (backup_id doesn't exist) */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description restore_create_failed or backup_lookup_failed — transient DB error; retry */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/restores": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List restore attempts for a resource + * @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. + */ + get: { + parameters: { + query?: { + limit?: number; + before?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Restore list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items?: { + /** + * Format: uuid + * @description Source backup the restore was taken from. + */ + backup_id?: string; + /** Format: date-time */ + created_at?: string; + error_summary?: string | null; + /** Format: date-time */ + finished_at?: string | null; + /** Format: uuid */ + restore_id?: string; + /** Format: date-time */ + started_at?: string; + /** @enum {string} */ + status?: "pending" | "running" | "ok" | "failed"; + }[]; + ok?: boolean; + total?: number; + }; + }; + }; + /** @description invalid_id or invalid_cursor */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden — caller doesn't own the resource */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description not_found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Resume a paused resource (restore from same data) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Resource resumed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uuid */ + id?: string; + message?: string; + ok?: boolean; + /** @enum {string} */ + status?: "active"; + /** Format: uuid */ + token?: string; + }; + }; + }; + /** @description invalid_id */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description upgrade_required — pause/resume requires Pro+ */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description not_found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description not_paused — the resource isn't currently paused */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description provider_failed — the provider-side grant failed; the DB row is unchanged */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resources/{id}/rotate-credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rotate credentials for a DB/cache/nosql resource + * @description Generates a new password and returns the updated connection_url. The old URL is immediately revoked. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Credentials rotated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + connection_url?: string; + ok?: boolean; + }; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stacks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all stacks owned by the caller's team + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Stack list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items?: { + /** Format: date-time */ + created_at?: string; + /** @description Deployment env (production / staging / dev / ...). Defaults to 'production' for legacy stacks pre-dating migration 015. */ + env?: string; + name?: string; + namespace?: string; + /** @description Root stack id when this is a promoted child. Empty string for the root. */ + parent_stack_id?: string; + /** @description Slug (same as path /stacks/{slug}) */ + stack_id?: string; + status?: string; + tier?: string; + }[]; + ok?: boolean; + total?: number; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stacks/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a single stack by slug + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Stack */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: date-time */ + created_at?: string; + /** @description Deployment env (production / staging / dev / ...). */ + env?: string; + name?: string; + namespace?: string; + ok?: boolean; + /** @description Root stack id when this is a promoted child. Empty string for the root. */ + parent_stack_id?: string; + /** @description Slug (same as path /stacks/{slug}) */ + stack_id?: string; + status?: string; + tier?: string; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Stack not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stacks/{slug}/confirm-deletion": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Confirm a pending stack deletion (paid tiers, Wave FIX-I) + * @description Stack-side counterpart of POST /api/v1/deployments/{id}/confirm-deletion. Same contract — see that endpoint for the full flow. + */ + post: { + parameters: { + query: { + token: string; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Stack deletion confirmed. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description missing_token */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description deletion_token_invalid */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + /** + * Cancel a pending stack deletion (paid tiers, Wave FIX-I) + * @description Stack-side counterpart of DELETE /api/v1/deployments/{id}/confirm-deletion. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cancellation confirmed. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not your stack */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No pending deletion to cancel */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stacks/{slug}/domains": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List custom domains bound to a stack */ + get: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Custom-domain list */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Stack not found or not owned by this team */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + /** + * Bind a custom hostname to a stack (Pro+) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Apex or subdomain, e.g. app.example.com */ + hostname: string; + }; + }; + }; + responses: { + /** @description Domain row created (pending verification) */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Body invalid or hostname malformed */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description upgrade_required — Pro plan or higher */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Stack not found or not owned by this team */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description hostname_taken — bound to another team's stack */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stacks/{slug}/domains/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Tear down the Ingress (best-effort) and remove the custom-domain binding */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Custom domain removed */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Custom domain not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description DB delete failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stacks/{slug}/domains/{id}/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Re-poll verification + ingress + certificate state for a custom domain (idempotent) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Latest state after this call's mutations */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Stack or domain not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stacks/{slug}/family": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get every env sibling of a stack (Pro+) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Any member of the family (root or child) — the handler walks up to the root and back down. */ + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Family list (root first) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + family?: { + /** Format: date-time */ + created_at?: string; + env?: string; + /** @description True for the family root (parent_stack_id is null). */ + is_root?: boolean; + /** Format: date-time */ + last_deploy_at?: string; + name?: string; + /** @description Empty string for the root; otherwise the root's id. */ + parent_stack_id?: string; + slug?: string; + status?: string; + tier?: string; + /** @description Best-effort: first exposed service's app_url, else first service URL, else empty. */ + url?: string; + }[]; + ok?: boolean; + /** @description Echo of the requested slug. */ + slug?: string; + total?: number; + }; + }; + }; + /** @description Unauthorized — session required */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Upgrade required — team is not on pro/team/growth. Response carries upgrade_url + agent_action. */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Stack not found or not owned by this team */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stacks/{slug}/promote": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Promote a stack from one env to another (Pro+) + * @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. + * + * Email-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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Source stack slug (the env you are promoting FROM) */ + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @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. */ + approval_id?: string; + /** @description Source env — defaults to source stack's env. Must match if provided. */ + from?: string; + /** @description Optional display name override for the new stack. */ + name?: string; + /** @description Target env (production, staging, dev, ...) — required. Anything other than 'development' triggers the email-link approval flow. */ + to: string; + }; + }; + }; + responses: { + /** @description Re-promoted into existing sibling stack — same slug, status reset to building */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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. */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid body, missing 'to', from==to, invalid env name, or approval_id mismatched (kind/from/to). */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized — session required */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Upgrade required — team is not on pro/team/growth. Response carries upgrade_url + agent_action. */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Blocked by team env_policy. Body: { error: 'env_policy_denied', env, action, role, allowed_roles, agent_action }. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Source stack not found, not owned by this team, OR approval_id does not match any row for this team */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Source env did not match the asserted 'from', OR approval_id is not in status='approved' */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description approval_id is past its 24h expiry window */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Live component-level health (public, cached 60s) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Status payload */ + 200: { + headers: { + /** @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. */ + "Cache-Control"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusResponse"]; + }; + }; + /** @description Failed to compute status (transient DB error). Retry with backoff. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/team": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the caller's team record + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Team record */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok: boolean; + team: components["schemas"]["TeamSelf"]; + }; + }; + }; + /** @description Missing or invalid session token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Team not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Lookup failed (transient DB error). Retry with backoff. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + /** + * Request team deletion (GDPR Article 17, owner only, 30-day grace) + * @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. + */ + delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Must match the team's visible slug exactly (case-insensitive). Fetch from GET /api/v1/team/summary if unknown. */ + confirm_team_slug: string; + }; + }; + }; + responses: { + /** @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. */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid body / confirm_team_slug. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Caller is not the team owner. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Team not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description slug_mismatch (confirm_team_slug did not match) or already_pending (deletion has already been requested or the team is tombstoned). */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + /** + * Rename the caller's team + * @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. + */ + patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description New display name. Whitespace is trimmed. Must be 1-200 chars after trim. */ + name: string; + }; + }; + }; + responses: { + /** @description Team updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok: boolean; + team: components["schemas"]["TeamSelf"]; + }; + }; + }; + /** @description Body invalid, name missing, or name longer than 200 chars */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Missing or invalid session token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Read-only session (admin impersonation) — mutations are blocked */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Update failed (transient DB error). Retry with backoff. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + trace?: never; + }; + "/api/v1/team/env-policy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the team's per-env access policy + * @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). + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Policy fetched */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + /** @description Shape: { : { : [, ...] } }. Known actions: deploy, delete_resource, vault_write. */ + policy?: { + [key: string]: { + [key: string]: string[]; + }; + }; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + /** + * Replace the team's per-env access policy (owner only) + * @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. + */ + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + [key: string]: { + [key: string]: string[]; + }; + }; + }; + }; + responses: { + /** @description Policy persisted; the response echoes the normalised policy. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid policy shape, unknown action, or malformed JSON. agent_action is populated when applicable. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Caller is not the team owner. Body: { error: 'owner_required', role, allowed_roles, agent_action }. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/team/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List pending invitations sent by this team (owner only) */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invitation list */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Owner only */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/team/invitations/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Revoke a pending invitation (owner only) */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revoked */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Owner only or invitation belongs to another team */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invitation not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/team/invitations/{id}/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Accept an invitation by its row id (authenticated user) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Accepted; response includes the granted role and an optional warning when an owner request was silently downgraded. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + role?: string; + /** @description Present iff the invitation requested role=owner but a silent downgrade to member occurred. */ + warning?: string; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invitation not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Expired, already used, or member-limit reached */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/team/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List members of the caller's team + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Members + limit */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + member_limit?: number; + members?: { + /** Format: email */ + email?: string; + /** Format: date-time */ + joined_at?: string; + role?: string; + /** Format: uuid */ + user_id?: string; + }[]; + ok?: boolean; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not a member of this team */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/team/members/invite": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Invite a user to the team (owner or admin) + * @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). + */ + post: { + parameters: { + query?: never; + header?: { + /** @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. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: email */ + email: string; + /** + * @default member + * @enum {string} + */ + role?: "admin" | "developer" | "viewer" | "member"; + }; + }; + }; + responses: { + /** @description Invitation created */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Body invalid, missing email, or invalid role */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Owner/admin role required */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Member limit reached / duplicate / already-a-member */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Rate limit exceeded (10 invites/hour/team) */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/team/members/leave": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Leave the team + * @description Removes the caller from their current team. Owners cannot leave — transfer ownership first. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Left the team */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Owner cannot leave (failed_precondition) */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/team/members/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Remove a member from the team (owner only) + * @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. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Member removed; response includes orphan_team_id */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + /** + * Format: uuid + * @description UUID of the freshly-created personal team the removed user was reassigned to. + */ + orphan_team_id?: string; + }; + }; + }; + /** @description Invalid user id, or target is the team's primary user (error code cannot_remove_primary) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Owner only */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description User not in team */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Cannot remove the owner */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + /** + * Change a member's role (owner only) + * @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. + */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + role: "admin" | "developer" | "viewer" | "member"; + }; + }; + }; + responses: { + /** @description Role updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + role?: string; + /** Format: uuid */ + user_id?: string; + }; + }; + }; + /** @description Invalid user id, invalid role, or attempt to assign owner (error code cannot_assign_owner_role) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Owner only */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description User not on this team */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + trace?: never; + }; + "/api/v1/team/members/{user_id}/promote-to-primary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Atomically transfer team primary + owner to the target user (owner only) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Primary transferred */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + /** Format: uuid */ + primary_user_id?: string; + /** Format: uuid */ + team_id?: string; + }; + }; + }; + /** @description Invalid user id */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Owner only */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Target user not on this team */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/team/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel a pending team deletion (owner only, inside 30-day grace) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Restored. Response: { ok, status, resumed_resource_count, days_remaining_at_cancel }. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Caller is not the team owner. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Team not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description not_pending — team is not in deletion_requested status. */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description grace_expired — 30 days have elapsed; restoration is no longer possible. */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/team/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read team preferences + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Team preferences */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + settings?: { + /** @description Convenience field — 24 for auto_24h, 0 for permanent. */ + default_deployment_ttl_hours?: number; + /** @enum {string} */ + default_deployment_ttl_policy?: "auto_24h" | "permanent"; + team_id?: string; + }; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Mutate team preferences (owner/admin only) + * @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. + */ + patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @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 {string} + */ + default_deployment_ttl_policy?: "auto_24h" | "permanent"; + }; + }; + }; + responses: { + /** @description Updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description invalid_ttl_policy — not 'auto_24h' or 'permanent' */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Insufficient role (owner/admin required) */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + trace?: never; + }; + "/api/v1/team/summary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Aggregated team counts for the dashboard sidebar (cached) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Aggregated team summary */ + 200: { + headers: { + /** @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. */ + "Cache-Control"?: string; + [name: string]: unknown; + }; + content: { + /** + * @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" + * } + */ + "application/json": components["schemas"]["TeamSummaryResponse"]; + }; + }; + /** @description Missing or invalid session token. Response includes agent_action pointing the user at https://instanode.dev/login. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Failed to compute summary (transient DB error). Retry with backoff. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/teams/{team_id}/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List pending invitations for a team (admin or owner only) */ + get: { + parameters: { + query?: never; + header?: never; + path: { + team_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invitations */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items?: components["schemas"]["InvitationResponse"][]; + }; + }; + }; + }; + }; + put?: never; + /** + * Invite a user to the team (admin or owner only) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + team_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: email */ + email: string; + /** @enum {string} */ + role: "admin" | "developer" | "viewer" | "member"; + }; + }; + }; + responses: { + /** @description Invitation created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvitationResponse"]; + }; + }; + /** @description Forbidden — admin role required */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/teams/{team_id}/invitations/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Revoke a pending invitation */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + team_id: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revoked */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/usage/wall": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Quota-wall nudge state (dashboard upgrade banner) + * @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. As of the 2026-06-05 strict-margin redesign Team has finite limits too, so Team callers can also approach a wall (next step above Team is Enterprise/contact-sales). Fails open — a DB error returns 503 rather than a misleading near_wall=false. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Usage-wall state */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * Format: date-time + * @description When the worker recorded the threshold crossing. Present only when near_wall is true. + */ + at?: string; + /** @description Which quota axis tripped (e.g. 'storage'). */ + axis?: string; + /** @description Measured usage at the time of the crossing. */ + current?: number; + /** @description The tier limit the usage is approaching. */ + limit?: number; + /** @description True when the team has crossed the 80% quota threshold within the freshness window. */ + near_wall: boolean; + ok: boolean; + /** @description current / limit as a percent. */ + percent_used?: number; + /** @description Which service the axis belongs to (postgres / redis / mongodb / …). */ + service?: string; + /** @description Team plan tier at the time the row was written. */ + tier?: string; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Failed to read usage-wall state from the platform DB */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/vault/copy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk-copy vault secrets from one env to another (Pro+) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description When true, returns the per-key plan but persists nothing. */ + dry_run?: boolean; + /** @description Source env name. Required. */ + from: string; + /** @description Optional allowlist of key names. Empty/omitted → copy all keys at source. */ + keys?: string[]; + /** @description When true, keys already in the target env are bumped to a new version. Default false. */ + overwrite?: boolean; + /** @description Target env name. Required. Must differ from 'from'. */ + to: string; + }; + }; + }; + responses: { + /** @description Plan + counts. Per-key actions are one of: copy, overwrite, skip, missing, quota_exceeded. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + blocked?: number; + copied?: number; + dry_run?: boolean; + from?: string; + missing?: number; + ok?: boolean; + plan?: { + action?: string; + key?: string; + }[]; + skipped?: number; + to?: string; + }; + }; + }; + /** @description Invalid body, missing from/to, from==to, or invalid env/key name */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized — session required */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Upgrade required — team is not on pro/team/growth. Response carries upgrade_url + agent_action. */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Blocked by team env_policy. Body: { error: 'env_policy_denied', env, action, role, allowed_roles, agent_action }. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/vault/{env}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List keys stored in an environment + * @description Returns key names only — values are NEVER returned by this endpoint. Use GET /api/v1/vault/{env}/{key} to read a value. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + env: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of keys */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + keys?: string[]; + ok?: boolean; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/vault/{env}/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read a secret (decrypted) + * @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. + */ + get: { + parameters: { + query?: { + version?: number; + }; + header?: never; + path: { + env: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Secret returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VaultGetResponse"]; + }; + }; + /** @description Secret not found for this team / env / key */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + /** + * Store an encrypted secret + * @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. + */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Environment scope (production, staging, dev, ...) */ + env: string; + /** @description Secret key (e.g. RAZORPAY_KEY_SECRET) */ + key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + value: string; + }; + }; + }; + responses: { + /** @description Secret stored */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VaultPutResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + post?: never; + /** Hard delete every version of a secret */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + env: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found (idempotent) */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/vault/{env}/{key}/rotate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rotate a secret (new value, version + 1) + * @description Convenience for PUT — preserves history but bumps the version visibly. Existing deployments continue to read v(N-1) until they redeploy. + * + * Idempotent: 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. + */ + post: { + parameters: { + query?: never; + header?: { + /** @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. */ + "Idempotency-Key"?: string; + }; + path: { + env: string; + key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + value: string; + }; + }; + }; + responses: { + /** @description Rotated */ + 200: { + headers: { + /** @description Which dedup path matched: explicit (Idempotency-Key header), fingerprint (body-fingerprint fallback), or miss (handler ran fresh). */ + "X-Idempotency-Source"?: "explicit" | "fingerprint" | "miss"; + /** @description Set to 'true' when the response was served from the idempotency cache instead of running the handler. */ + "X-Idempotent-Replay"?: "true"; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VaultPutResponse"]; + }; + }; + /** @description Idempotency-Key already used with a different body (error=idempotency_key_conflict). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/webhooks/{token}/requests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List received webhook payloads */ + get: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of stored requests with headers and body */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/whoami": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Identity probe — confirms the bearer token is valid and returns the team it grants access to + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Identity confirmed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WhoamiResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/approve/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Click-through endpoint for email-link promote approvals + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description URL-safe base64 token from the approval email. */ + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Approved — redirect to dashboard */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing token (HTML) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Token does not match any row (HTML) */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Token expired or already used (HTML) */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Per-IP rate limit hit (HTML) */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/cli": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Start a CLI device-flow login session + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + anon_tokens?: string[]; + }; + }; + }; + responses: { + /** @description Session created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uri */ + auth_url?: string; + /** @description Seconds (600) */ + expires_in?: number; + ok?: boolean; + session_id?: string; + }; + }; + }; + /** @description Failed to create login session */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/cli/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Poll a CLI device-flow login session for completion + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Login complete */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + api_key?: string; + claimed_tokens?: string[]; + /** Format: email */ + email?: string; + ok?: boolean; + team_name?: string; + tier?: string; + }; + }; + }; + /** @description Still pending */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing session id */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Session not found or expired */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/email/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Consume a magic link, mint a session JWT, 302 to + * @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). + */ + get: { + parameters: { + query: { + t: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to ?session_token= */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Token missing, expired, already used, or invalid */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Database / JWT signing failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/email/confirm-deletion": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Email-link 302 redirect to the dashboard confirm page (Wave FIX-I) + * @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. + */ + get: { + parameters: { + query: { + t: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to dashboard confirm page */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing token query parameter */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/email/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send a passwordless magic-link sign-in email + * @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=. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: email */ + email: string; + /** + * Format: uri + * @description Where to send the user after sign-in. Validated against the allowlist; off-list collapses to the default. + */ + return_to?: string; + }; + }; + }; + responses: { + /** @description Magic link sent (or silently dropped — body is invariant by design) */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Body invalid or email malformed */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/exchange": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Exchange the AUTH-004 bridge cookie for a session JWT + * @description Final leg of the AUTH-004 cross-origin sign-in handshake. The /auth/email/callback and /auth/github/callback handlers set a short-lived HttpOnly auth_exchange_cookie and 302 to https://instanode.dev/login/callback?signed_in=1. The dashboard then makes a credentials:include POST to this endpoint. CORS contract: response MUST include Access-Control-Allow-Origin: https://instanode.dev AND Access-Control-Allow-Credentials: true — the browser blocks the read otherwise. Request MUST be a CORS-simple POST (no custom headers like Accept: application/json), since adding one forces a preflight that PreflightAllowlist may reject. Returns 200 + the bearer JWT (24h, HS256, aud=https://api.instanode.dev) on success. The 2026-05-29 to 2026-05-30 prod-login outage chained three failures along this exact endpoint — documenting it here so any future regression is catchable by the cross-stack contract gate (api PR #202). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description No body. The bridge cookie travels in the Cookie header via credentials:include. */ + requestBody?: { + content: { + "*/*"?: never; + }; + }; + responses: { + /** @description Cookie verified; JWT minted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok: boolean; + /** @description Session JWT — store in localStorage and send as Authorization: Bearer for /api/v1/* calls */ + token: string; + }; + }; + }; + /** @description Bridge cookie missing / expired (canonical envelope with error code cookie_missing_or_expired) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Cookie present but signature invalid or aud mismatch */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description JWT signing failed (downstream) */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/github": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Exchange a GitHub OAuth authorization code for a session JWT + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + code: string; + }; + }; + }; + responses: { + /** @description Session issued */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: email */ + email?: string; + ok?: boolean; + /** Format: uuid */ + team_id?: string; + token?: string; + /** Format: uuid */ + user_id?: string; + }; + }; + }; + /** @description Body invalid or missing code */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description GitHub rejected the authorization code */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description GitHub OAuth not configured / user upsert failed / JWT signing failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/github/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Browser-driven GitHub OAuth: exchange code + 302 to ?session_token= + * @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. + */ + get: { + parameters: { + query: { + code: string; + state: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to ?session_token= */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing code/state, or state mismatch / expired */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description GitHub rejected the code */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description OAuth not configured / user upsert / JWT signing failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/github/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Browser-driven GitHub OAuth: stash CSRF cookie + 302 to GitHub + * @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. + */ + get: { + parameters: { + query?: { + return_to?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to GitHub authorize URL */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description GitHub OAuth not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Log out — revoke the current session token server-side + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Session revoked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current user info */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User and team info */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthMeResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/billing/checkout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Legacy alias for POST /api/v1/billing/checkout + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + plan: "hobby" | "hobby_plus" | "pro" | "team"; + }; + }; + }; + responses: { + /** @description Subscription created — redirect user to short_url */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + /** Format: uri */ + short_url?: string; + subscription_id?: string; + }; + }; + }; + /** @description Invalid plan */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid session token */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Razorpay rejected the create-subscription call */ + 502: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Razorpay not configured on this environment */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cache/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Provision a Redis cache + * @description Returns a real redis:// connection string with ACL namespace isolation. Anonymous tier: 5MB memory, 24h TTL. + * + * Supports Stripe/AWS-style idempotency via the optional Idempotency-Key request header. + */ + post: { + parameters: { + query?: never; + header?: { + /** @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. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ProvisionRequest"]; + }; + }; + responses: { + /** @description Cache provisioned */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CacheProvisionResponse"]; + }; + }; + /** @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). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Idempotency-Key already used with a different body (error=idempotency_key_conflict). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Provisioning failed (transient). Retry with backoff. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/claim": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Claim anonymous resources to a permanent account + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ClaimRequest"]; + }; + }; + responses: { + /** @description Magic link sent to email */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClaimResponse"]; + }; + }; + /** @description Account created, resources transferred (legacy direct-claim flow) */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClaimResponse"]; + }; + }; + /** @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). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Onboarding token already used (single-use claim) */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/claim/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Preview which resources a claim would attach + * @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. + */ + get: { + parameters: { + query: { + /** @description Signed onboarding JWT (the upgrade_jwt field from any anonymous provisioning response, or extracted from the upgrade URL). */ + t: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Preview of claimable resources */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClaimPreviewResponse"]; + }; + }; + /** @description Token missing or malformed */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Token expired or signature invalid */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/db/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Provision a Postgres database + * @description Returns a real postgres:// connection string with pgvector pre-installed. Anonymous tier: 10MB, 2 connections, 24h TTL. + * + * Supports Stripe/AWS-style idempotency via the optional Idempotency-Key request header — see the parameter description below. + */ + post: { + parameters: { + query?: never; + header?: { + /** @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. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ProvisionRequest"]; + }; + }; + responses: { + /** @description Database provisioned */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DBProvisionResponse"]; + }; + }; + /** @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). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Provisioning failed (transient). Retry with backoff. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/deploy/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Deploy a container application + * @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. + * + * Supports 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. + */ + post: { + parameters: { + query?: never; + header?: { + /** @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. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["DeployRequest"]; + }; + }; + responses: { + /** @description Deployment accepted, building */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeployResponse"]; + }; + }; + /** @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) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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. */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Blocked by team env_policy, OR resource_binding_forbidden (binding references a resource owned by a different team) */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description resource_binding_not_found — the resource or family root id supplied in resource_bindings does not exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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) */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Compute backend unavailable or service disabled, OR resource_binding_lookup_failed (transient DB error during binding resolution) */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/deploy/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get deployment status */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployment record */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeployResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not your deployment */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + /** Tear down and delete a deployment */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deletion enqueued */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not your deployment */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/deploy/{id}/env": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Update env vars (redeploy required to apply) + * @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. + */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + env?: { + [key: string]: string; + }; + }; + }; + }; + responses: { + /** @description Env vars updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeployResponse"]; + }; + }; + }; + }; + trace?: never; + }; + "/deploy/{id}/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Stream deployment logs (Server-Sent Events) */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description text/event-stream of log lines, terminated by 'data: [end]' */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Deployment still building */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/deploy/{id}/redeploy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Redeploy with the latest stored env vars + * @description Re-resolves any vault:// references and rolls out a new revision. Use after PATCH /deploy/{id}/env or after rotating a vault secret. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redeploy accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/deploy/{id}/wake": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Wake a scaled-to-zero (sleeping) deployment + * @description Scale-to-zero (Task #54). Scales an idle, descheduled app back to one replica and clears its sleeping state. The app becomes reachable once its pod is Ready (a one-time cold start — a request that races the wake gets the ingress's upstream-down response until the pod is up). Idempotent: waking an already-awake app just refreshes its last-activity marker so the idle-scaler won't immediately re-deschedule it. Returns 501 when scale-to-zero is not enabled on the platform (the default). Cross-tenant requests return 404. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Deployment id (UUID or short app_id slug). */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployment woken */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeployResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found (or owned by another team) */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description scale_to_zero_disabled — scale-to-zero is not enabled on this platform (default). */ + 501: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description wake_failed — transient failure scaling the app; retry. */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/healthz": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health check (shallow liveness) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Service is healthy */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/livez": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Liveness probe + * @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). + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Process is alive */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + alive: true; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/llms-full.txt": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Full LLM-targeted product docs (302 to marketing) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to https://instanode.dev/llms-full.txt */ + 302: { + headers: { + Location?: string; + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/llms.txt": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Agent discovery doc (302 to marketing) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to https://instanode.dev/llms.txt */ + 302: { + headers: { + Location?: string; + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Prometheus metrics scrape endpoint + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Prometheus text-format metrics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": unknown; + }; + }; + /** @description METRICS_TOKEN is configured and the supplied bearer did not match */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/nosql/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Provision a MongoDB database + * @description Returns a real mongodb:// connection string scoped to a per-token database. Anonymous tier: 5MB, 2 connections, 24h TTL. + * + * Supports Stripe/AWS-style idempotency via the optional Idempotency-Key request header. + */ + post: { + parameters: { + query?: never; + header?: { + /** @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. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ProvisionRequest"]; + }; + }; + responses: { + /** @description MongoDB database provisioned */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NoSQLProvisionResponse"]; + }; + }; + /** @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). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Idempotency-Key already used with a different body (error=idempotency_key_conflict). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Provisioning failed (transient). Retry with backoff. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/openapi.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Machine-readable OpenAPI 3.1 description of this API + * @description Returns this very document. Self-describing endpoint that agents can read to discover every other route. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OpenAPI 3.1 JSON spec */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/queue/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Provision a NATS JetStream queue + * @description Returns a real nats:// connection string with per-account subject isolation. Anonymous tier: 24h TTL. + * + * Supports Stripe/AWS-style idempotency via the optional Idempotency-Key request header. + */ + post: { + parameters: { + query?: never; + header?: { + /** @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. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ProvisionRequest"]; + }; + }; + responses: { + /** @description Queue provisioned */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QueueProvisionResponse"]; + }; + }; + /** @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). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Idempotency-Key already used with a different body (error=idempotency_key_conflict). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Provisioning failed (transient). Retry with backoff. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/razorpay/webhook": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Razorpay subscription event webhook (signature-verified) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": Record; + }; + }; + responses: { + /** @description Event processed (or ignored for unhandled event types) */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description invalid_signature or invalid_payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/readyz": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Deep readiness check (multi-component) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Service is ready (overall=ok) or degraded but still serving (overall=degraded). The body's checks[] enumerates per-component status. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadinessResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/resources/{token}/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Stream pod logs for an isolated (growth-tier) resource + * @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). + */ + get: { + parameters: { + query?: { + tail?: number; + }; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description text/event-stream of log lines terminated by 'data: [end]' */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description invalid_token, not_growth, or unsupported_type */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Resource or backing pod not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Resource has no provider namespace yet — still provisioning */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Log streaming unavailable (no k8s client) */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stacks/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Deploy a multi-service stack + * @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). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["StackRequest"]; + }; + }; + responses: { + /** @description Stack accepted, building */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StackResponse"]; + }; + }; + /** @description Invalid manifest, missing tarball for a declared service, or unresolved service:// reference */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Anonymous rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Compute backend unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stacks/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get stack status + * @description Returns per-service status. The overall stack status is 'healthy' only when every service is healthy. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Stack record */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StackResponse"]; + }; + }; + /** @description Stack not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + /** Tear down and delete a stack */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deletion enqueued */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Stack not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stacks/{slug}/env": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Update env vars on a stack (persisted; applied on next redeploy) + * @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. + */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Env vars to upsert. Empty-string value deletes a key. */ + env: { + [key: string]: string; + }; + }; + }; + }; + responses: { + /** @description Env vars persisted; response includes the full merged env map. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Full env set on the stack AFTER the merge — caller does not need to re-GET. */ + env?: { + [key: string]: string; + }; + message?: string; + ok?: boolean; + }; + }; + }; + /** @description Body missing, env is empty, or an env-var key fails the POSIX [A-Z_][A-Z0-9_]* shape (error=invalid_env_key). */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Stack not found or not owned by this team */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Stack is mid-teardown and cannot be modified (error=stack_deleting). */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Merged env_vars payload exceeds 64KiB (error=env_too_large). */ + 413: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + trace?: never; + }; + "/stacks/{slug}/logs/{svc}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Stream service logs from a stack (Server-Sent Events) + * @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. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + svc: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description text/event-stream of log lines terminated by 'data: [end]' */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Stack not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Compute backend log stream failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stacks/{slug}/redeploy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rebuild + rolling update for one or more services in the stack */ + post: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redeploy accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized — redeploy mutates the stack and requires a session */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Stack not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Onboarding bounce — always 302 to the dashboard claim page + * @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. + */ + get: { + parameters: { + query?: { + /** @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. */ + t?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @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. */ + 302: { + headers: { + /** @description Dashboard claim URL with the JWT echoed in the t= query param (or no t= when omitted by the caller) */ + Location?: string; + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/storage/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Provision S3-compatible object storage + * @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): + * + * - '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. + * + * - 'shared-master-key' (legacy DO Spaces rows): returns the platform master key + prefix. Isolation is by convention only; new tenants do NOT land here. + * + * - '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. + * + * Always 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. + */ + post: { + parameters: { + query?: never; + header?: { + /** @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. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ProvisionRequest"]; + }; + }; + responses: { + /** @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. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageProvisionResponse"]; + }; + }; + /** @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). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Storage limit reached. Includes agent_action and upgrade_url. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Idempotency-Key already used with a different body (error=idempotency_key_conflict). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Anonymous fingerprint limit exceeded. Includes agent_action and upgrade_url. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Object storage is not configured on this environment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/storage/{token}/presign": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Mint a short-lived presigned S3 URL (broker-mode access) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The storage resource's token (returned by /storage/new). */ + token: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description Lifetime of the signed URL in seconds. Default 600, max 3600. + * @default 600 + */ + expires_in?: number; + /** @description Object key, relative to the resource's prefix. Leading slashes + '../' components are stripped. */ + key: string; + /** + * @description S3 verb to sign for. + * @enum {string} + */ + operation: "GET" | "PUT"; + }; + }; + }; + responses: { + /** @description Signed URL minted. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: date-time */ + expires_at?: string; + key?: string; + method?: string; + object_key?: string; + ok?: boolean; + url?: string; + }; + }; + }; + /** @description invalid_token, invalid_operation, or invalid_key. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description resource_not_found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description resource_inactive — paused, expired, or deleted. */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description service_disabled or sign_failed (object storage not configured). */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/vector/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Provision a pgvector-enabled Postgres database + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["VectorProvisionRequest"]; + }; + }; + responses: { + /** @description Vector database provisioned */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VectorProvisionResponse"]; + }; + }; + /** @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). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Quota exceeded, feature requires upgrade, OR free-tier recycle requires claim (error=free_tier_recycle_requires_claim). */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Provisioning failed (transient). Retry with backoff. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhook/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Provision a webhook receiver + * @description Returns a public receive_url that accepts any HTTP method and stores the payload (headers + body) in Redis for 24h. + * + * Supports Stripe/AWS-style idempotency via the optional Idempotency-Key request header. + */ + post: { + parameters: { + query?: never; + header?: { + /** @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. */ + "Idempotency-Key"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ProvisionRequest"]; + }; + }; + responses: { + /** @description Webhook receiver provisioned */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookProvisionResponse"]; + }; + }; + /** @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). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Idempotency-Key already used with a different body (error=idempotency_key_conflict). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Provisioning failed (transient). Retry with backoff. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhook/receive/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Receive a webhook payload + * @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. + */ + post: { + parameters: { + query?: never; + header?: { + /** @description sha256= — required only when the webhook resource has hmac_secret configured. */ + "X-Hub-Signature-256"?: string; + /** @description Opaque key (e.g. from Stripe's Idempotency-Key); two requests with the same key replay the original response. */ + "X-Idempotency-Key"?: string; + }; + path: { + token: string; + }; + cookie?: never; + }; + /** @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. */ + requestBody?: { + content: { + "*/*": unknown; + "application/json": unknown; + "application/octet-stream": unknown; + "application/x-www-form-urlencoded": unknown; + "text/plain": unknown; + }; + }; + responses: { + /** @description Payload stored */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id?: string; + ok?: boolean; + }; + }; + }; + /** @description HMAC signature missing or invalid (when hmac_secret is set on the resource). */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Token not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Token exists but resource status != 'active'. */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Request body exceeds the 1 MiB cap. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/brevo/{secret}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Receive a Brevo transactional-email delivery event (PUBLIC, URL-token auth) + * @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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Shared secret matching BREVO_WEBHOOK_SECRET. Mismatch returns 401. Never log or echo this value. */ + secret: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Brevo-side event timestamp. We stamp delivered_at = NOW() server-side instead of trusting upstream clock. */ + date?: string; + /** @description Recipient address. Logged masked-only. */ + email?: string; + /** + * @description Brevo event name. 'spam' is an alias for 'complaint' (older integrations). + * @enum {string} + */ + event: "delivered" | "soft_bounce" | "hard_bounce" | "blocked" | "complaint" | "spam" | "deferred" | "unsubscribed" | "error"; + /** @description Brevo's opaque messageId — the lookup key against forwarder_sent.provider_id. */ + "message-id"?: string; + /** @description Free-text reason for failure events (bounces, blocked, error). Logged but not persisted; raw payload is never stored. */ + reason?: string; + /** @description Subject line at send time. Optional; not persisted. */ + subject?: string; + }; + }; + }; + responses: { + /** @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). */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description invalid_payload (malformed JSON) OR payload_too_large (> 16 KiB) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description unauthorized — URL :secret did not match BREVO_WEBHOOK_SECRET */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description internal_error — DB unreachable. Brevo retries with exponential backoff, which is the right behaviour. */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/github/{webhook_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Receive a GitHub push event (PUBLIC, signed) + * @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. + */ + post: { + parameters: { + query?: never; + header: { + /** @description GitHub-formatted signature: 'sha256=' where hex is HMAC-SHA256(secret, body). */ + "X-Hub-Signature-256": string; + /** @description GitHub event type. Only 'push' triggers a deploy; 'ping' acknowledges. */ + "X-GitHub-Event": "push" | "ping"; + }; + path: { + /** @description Connection id returned by POST /api/v1/deployments/{id}/github. */ + webhook_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Commit SHA after the push (becomes the deploy revision). */ + after?: string; + pusher?: { + name?: string; + }; + /** @example refs/heads/main */ + ref?: string; + repository?: { + full_name?: string; + }; + }; + }; + }; + responses: { + /** @description Event accepted (ping / no-op for non-push event / branch_mismatch / duplicate) */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Deploy enqueued — worker will drain shortly */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description invalid_payload — body is not valid JSON */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description signature_invalid — X-Hub-Signature-256 did not verify */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Webhook not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description rate_limited — connection exceeded 10 deploys/hour */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description encryption_unavailable / decrypt_failed / enqueue_failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @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. */ + AuditExportItem: { + /** @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. */ + actor_email_masked?: string | null; + /** + * Format: uuid + * @description Null when the row came from a system actor (worker, billing webhook, dunning job). + */ + actor_user_id?: string | null; + /** Format: date-time */ + created_at: string; + /** Format: uuid */ + id: string; + /** @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. */ + kind: string; + /** @description Arbitrary k/v stamped at emit time. Per-kind shape — see individual emit sites. */ + metadata?: { + [key: string]: unknown; + } | null; + }; + /** @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). */ + AuthMeResponse: { + /** @description Unguessable URL segment for the admin customer-management endpoints. Present only for admins when ADMIN_PATH_PREFIX is configured. */ + admin_path_prefix?: string; + email?: string; + /** @description A/B experiment variant assignments keyed by experiment name, bucketed by team_id. */ + experiments?: { + [key: string]: string; + }; + /** @description Email of the admin who started an impersonation session. Present only on impersonated sessions. */ + impersonated_by?: string; + /** @description Present (always true) only when the caller's email is on the ADMIN_EMAILS allowlist. Absent for every non-admin caller. */ + is_platform_admin?: boolean; + ok?: boolean; + /** @description Human-readable plan name for the current tier (from plans.Registry). */ + plan_display_name?: string; + /** @description Present (always true) only when the session JWT carries read_only=true — i.e. an admin impersonation session. Absent for normal sessions. */ + read_only?: boolean; + /** Format: uuid */ + team_id?: string; + /** + * @description The team's current plan tier. + * @enum {string} + */ + tier?: "anonymous" | "free" | "hobby" | "hobby_plus" | "pro" | "team" | "growth"; + /** Format: uuid */ + user_id?: string; + }; + /** @description Payment method on file. null when the team has no Razorpay subscription, or has a subscription but no successful charge yet. */ + BillingPaymentMethod: { + /** @description Card network (e.g. 'visa', 'mastercard') — present only for type=card */ + brand?: string | null; + /** @description Last 4 digits — present only for type=card */ + last4?: string | null; + /** + * @description Razorpay payment method type + * @enum {string} + */ + type: "card" | "upi" | "netbanking" | "wallet"; + /** @description UPI VPA (e.g. 'name@hdfc') — present only for type=upi */ + vpa?: string | null; + }; + /** @description Aggregated billing state served by GET /api/v1/billing. */ + BillingStateResponse: { + /** @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 */ + amount_inr?: number | null; + /** @description Owner's email — best-effort; empty string when no owner user row exists */ + billing_email: string; + /** + * Format: date-time + * @description ISO timestamp for next renewal (Razorpay current_end). null when no active subscription + */ + next_renewal_at?: string | null; + ok: boolean; + payment_method?: components["schemas"]["BillingPaymentMethod"] | null; + /** @description Razorpay customer id. Reserved for future use — always null today (Razorpay subscriptions don't require a pre-created customer record) */ + razorpay_customer_id?: string | null; + /** @description Razorpay subscription id (sub_xxx). null until the team starts a checkout flow. Useful for support tickets */ + razorpay_subscription_id?: string | null; + /** + * @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 {string} + */ + subscription_status: "none" | "active" | "cancelled"; + /** + * @description Current plan tier from the team record + * @enum {string} + */ + tier: "anonymous" | "free" | "hobby" | "hobby_plus" | "pro" | "team" | "growth"; + }; + /** @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). */ + BillingUsageResponse: { + /** + * Format: date-time + * @description When the aggregation was computed. Useful for stale-while-revalidate displays and for debugging cache-vs-live discrepancies. + */ + as_of: string; + /** @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. */ + freshness_seconds: number; + /** @enum {boolean} */ + ok: true; + /** @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. */ + usage: { + deployments?: components["schemas"]["UsageMetric"]; + members?: components["schemas"]["UsageMetric"]; + mongodb?: components["schemas"]["UsageMetric"]; + postgres?: components["schemas"]["UsageMetric"]; + redis?: components["schemas"]["UsageMetric"]; + vault?: components["schemas"]["UsageMetric"]; + webhooks?: components["schemas"]["UsageMetric"]; + }; + }; + CacheProvisionResponse: { + /** + * Format: uri + * @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. + */ + claim_url?: string; + /** @description redis:// connection string with ACL namespace isolation. Use this from external callers. */ + connection_url?: string; + /** @description True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool. Authenticated provisions only. */ + dedicated?: boolean; + /** @description Resolved environment bucket (defaults to 'development' when omitted). */ + env?: string; + /** @description Present only when env was omitted and defaulted ('default_no_env_specified'). */ + env_override_reason?: string; + /** + * Format: date-time + * @description Anonymous-tier only. RFC3339 24h-TTL expiry. T19 P0-2 (BugHunt 2026-05-20). + */ + expires_at?: string; + /** + * Format: uuid + * @description Resource row id. + */ + id?: string; + /** @description Cluster-internal redis:// URL routed via instant-redis-proxy. Use this when calling from a workload deployed inside the instanode cluster. */ + internal_url?: string; + /** @description All keys must use this prefix for namespace isolation */ + key_prefix?: string; + limits?: { + expires_in?: string; + memory_mb?: number; + }; + /** @description Human-readable label supplied on the request (or the generated default). */ + name?: string; + note?: string; + ok?: boolean; + tier?: string; + /** Format: uuid */ + token?: string; + /** + * Format: uri + * @description Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow. + */ + upgrade?: string; + /** @description Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions. */ + upgrade_jwt?: string; + /** @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. */ + warning?: string; + }; + CapabilitiesResponse: { + /** @description Mailto link for enterprise inquiries. */ + contact?: string; + /** + * Format: uri + * @description Pointer to the LLM-targeted product docs surface. + */ + docs?: string; + ok: boolean; + /** @description Tier rows in upgrade-ladder order (anonymous first → team last). */ + tiers: components["schemas"]["TierCapabilities"][]; + }; + ClaimPreviewResponse: { + /** + * Format: date-time + * @description When the onboarding JWT itself expires (typically 7 days from issue). Unrelated to per-resource 24h TTL. + */ + expires_at?: string; + /** @description All anonymous resources that this JWT would attach to the new team if /claim were posted. Canonical envelope field — matches /api/v1/resources, /api/v1/deployments, /api/v1/audit, and every other list endpoint on the platform. */ + items?: components["schemas"]["ResourceItem"][]; + ok?: boolean; + /** + * @deprecated + * @description DEPRECATED — legacy alias of items. Kept populated for back-compat with the dashboard and existing curl recipes; new clients should read items. B5-P1-3 (BugBash 2026-05-20). + */ + resources?: components["schemas"]["ResourceItem"][]; + /** @description True when the onboarding JWT is well-formed, unexpired, and not yet claimed. */ + token_valid?: boolean; + }; + /** @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. */ + ClaimRequest: { + /** + * Format: email + * @description RFC 5322 email address. Validated server-side via net/mail.ParseAddress — invalid syntax returns 400 with error=invalid_email_format. + */ + email: string; + /** + * @deprecated + * @description Deprecated alias for token (kept for backward compatibility). New callers should send token instead. + */ + jwt?: string; + /** @description Optional human-readable team name. Defaults to the email when omitted. */ + team_name?: string; + /** @description Onboarding token. Read this directly from the upgrade_jwt field of any anonymous provisioning response — no need to string-parse the upgrade URL. */ + token: string; + }; + ClaimResponse: { + message?: string; + ok?: boolean; + /** @description 24h JWT for immediate authenticated API use */ + session_token?: string; + /** Format: uuid */ + team_id?: string; + /** Format: uuid */ + user_id?: string; + }; + /** @description One row of the /api/v1/status components array. last_24h_samples is exactly 96 booleans (96 x 15min = 24h), oldest first. */ + ComponentStatus: { + /** + * @description Ordering bucket. Render order: core then compute then edge. + * @enum {string} + */ + category: "core" | "compute" | "edge"; + /** + * @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 {string} + */ + current_status: "operational" | "degraded" | "down"; + /** @description Optional one-liner describing the component. Omitted when blank. */ + description?: string; + /** @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. */ + last_24h_samples: boolean[]; + /** @description Display name for the dashboard's status page. */ + name: string; + /** @description Stable component identifier (e.g. 'api', 'provisioner', 'worker', 'marketing'). */ + slug: string; + /** @description Percent healthy across the last 30 days. -1 sentinel = no samples in the window. */ + uptime_30d_pct: number; + /** @description Percent healthy across the last 7 days. -1 sentinel = no samples in the window. */ + uptime_7d_pct: number; + }; + DBProvisionResponse: { + /** + * Format: uri + * @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. + */ + claim_url?: string; + /** @description postgres:// connection string with pgvector pre-installed. Use this from external callers. */ + connection_url?: string; + /** @description True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool. Authenticated provisions only. */ + dedicated?: boolean; + /** @description Resolved environment bucket the resource landed in (defaults to 'development' when env was omitted — see migration 026). */ + env?: string; + /** @description Present only when the request omitted env and the API defaulted it (value 'default_no_env_specified'). Absent when env was sent explicitly. */ + env_override_reason?: string; + /** + * Format: date-time + * @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. + */ + expires_at?: string; + /** + * Format: uuid + * @description Resource row id. + */ + id?: string; + /** @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. */ + internal_url?: string; + limits?: { + connections?: number; + expires_in?: string; + storage_mb?: number; + }; + /** @description Human-readable label supplied on the request (or the generated default). */ + name?: string; + note?: string; + ok?: boolean; + tier?: string; + /** Format: uuid */ + token?: string; + /** + * Format: uri + * @description Anonymous-tier only. Pre-baked GET /start?t= URL the agent can hand to the user to drive the dashboard claim flow. + */ + upgrade?: string; + /** @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. */ + upgrade_jwt?: string; + /** @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. */ + warning?: string; + }; + /** @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. */ + DeployItem: { + /** @description IP/CIDR allowlist for a private deployment. Always present (empty [] for public deploys). */ + allowed_ips?: string[]; + /** @description 8-char public identifier used in the URL */ + app_id?: string; + /** Format: date-time */ + created_at?: string; + /** @description Application env vars. Credential values are masked '***'; the internal _name key is never present. */ + env?: { + [key: string]: string; + }; + /** @description Environment scope (production / staging / dev / ...). */ + environment?: string; + /** @description Present only when the deployment carries a non-empty error_message. */ + error?: string; + /** + * Format: date-time + * @description Auto-expiry timestamp. Omitted entirely when ttl_policy is permanent. + */ + expires_at?: string; + /** @description Absolute URL to extend the auto-expiry window. Present only when expires_at is set. */ + extend_ttl_url?: string; + /** @description Structured failure autopsy. Present only when status is 'failed' and an autopsy row exists. */ + failure?: { + /** @description Raw error string from the failed stage. */ + event?: string; + /** @description Human-readable remediation hint. */ + hint?: string; + /** @description Tail of the kaniko build log captured at failure time. */ + last_lines?: string[]; + /** Format: date-time */ + occurred_at?: string; + /** @description Failure classification (e.g. build_failed, deadline_exceeded). */ + reason?: string; + }; + /** Format: uuid */ + id?: string; + /** @description Absolute URL to convert an auto-expiring deploy to permanent. Present only when expires_at is set. */ + make_permanent_url?: string; + /** @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. */ + name?: string; + /** @description Notify-webhook delivery attempt count. Present only when notify_webhook is configured. */ + notify_attempts?: number; + /** @description Whether a notify-webhook signing secret is configured. Present only when notify_webhook is configured. */ + notify_secret_set?: boolean; + /** @description Lifecycle state of the notify webhook. */ + notify_state?: string; + /** @description Caller-supplied status webhook URL (echoed back; the plaintext secret is never returned). */ + notify_webhook?: string; + port?: number; + /** @description True when the deployment is IP-allowlist gated (Pro+ feature). */ + private?: boolean; + /** @description Opaque compute-backend handle (k8s namespace/deployment ref). */ + provider_id?: string; + /** @description Count of TTL-expiry reminder emails sent. Present only when expires_at is set. */ + reminders_sent?: number; + /** + * Format: uuid + * @description Present only when the deployment is linked to a provisioned resource. + */ + resource_id?: string; + /** @enum {string} */ + status?: "building" | "deploying" | "healthy" | "failed" | "stopped" | "expired"; + /** Format: uuid */ + team_id?: string; + tier?: string; + /** @description Public-facing alias for app_id (same 8-char value). */ + token?: string; + /** @description Either 'permanent' or an auto-expiry policy. Always present so callers can branch on permanence. */ + ttl_policy?: string; + /** Format: date-time */ + updated_at?: string; + url?: string; + }; + DeployRequest: { + /** @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. */ + allowed_ips?: string; + /** @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). */ + env?: string; + /** @description Optional JSON object of env vars to inject into the deployed pod on the FIRST build — e.g. '{"DATABASE_URL":"postgres://...","REDIS_URL":"redis://..."}'. Avoids the (POST /deploy/new) → (PATCH /env) → (POST /redeploy) round-trip pattern. Values may use 'vault://KEY' refs which resolve at deploy time. Keys starting with underscore are reserved and ignored. */ + env_vars?: string; + /** @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. */ + name: string; + /** @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'. */ + notify_webhook?: string; + /** @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. */ + notify_webhook_secret?: string; + /** @description Container port (default 8080) */ + port?: number; + /** @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." */ + private?: string; + /** + * @description When true with a matching 'name', replace the existing deployment in place (same app_id + URL, same provider_id) instead of minting a fresh one. The platform looks up the team's most-recent non-terminal deployment whose env_vars._name matches the supplied 'name' (scoped to the resolved 'env'), then routes through the same compute path as POST /deploy/:id/redeploy. Closes the agent-UX gap (2026-05-30): multiple /deploy/new calls for the same logical app used to fan out into N distinct URLs because there was no way to upsert by name. Truthy values: 'true', '1', 'yes' (case-insensitive); anything else is false. Errors: 404 no_existing_deployment_to_redeploy when no live row matches (note: an empty 'name' is rejected upstream by the standard name_required check, before this flag is even consulted) (omit 'redeploy' to create a new deployment, or call GET /api/v1/deployments first to discover the id); 409 not_ready when the matching row exists but has no provider_id yet (initial build still running). Default false: leaving the field absent keeps the legacy fan-out behaviour. + * @default false + */ + redeploy: boolean; + /** @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-..."}'. */ + resource_bindings?: string; + /** + * Format: binary + * @description gzipped tar archive containing the Dockerfile + source (max 10 MB). Over the cap returns 413 tarball_too_large with an agent_action — slim the upload (exclude node_modules/.git/build output) or deploy a prebuilt image instead of uploading source. 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. + */ + tarball: string; + /** + * @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 {string} + */ + ttl_policy?: "auto_24h" | "permanent"; + }; + DeployResponse: { + /** @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. */ + agent_action?: string; + item?: { + /** @description CIDRs / IPs whitelisted on the Ingress when private=true. Empty array on a public deploy. */ + allowed_ips?: string[]; + /** @description 8-char public identifier used in the URL */ + app_id?: string; + /** @description Env vars map — vault://KEY references resolve at deploy time */ + env?: { + [key: string]: string; + }; + /** @description Env scope (production/staging/dev). Note: 'env' on this object is the env_vars map, not the scope. */ + environment?: string; + /** + * Format: date-time + * @description Wave FIX-J. When the deploy auto-expires. Omitted when ttl_policy='permanent'. + */ + expires_at?: string; + /** @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'. */ + extend_ttl_url?: string; + /** Format: uuid */ + id?: string; + /** @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'. */ + make_permanent_url?: string; + /** @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. */ + name?: string; + /** @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. */ + notify_attempts?: number; + /** @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. */ + notify_secret_set?: boolean; + /** + * @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 {string} + */ + notify_state?: "unset" | "pending" | "sent" | "failed"; + /** @description Echoed-back webhook URL when set on POST /deploy/new. Empty string when no webhook was configured for this deployment. */ + notify_webhook?: string; + port?: number; + /** @description True when the Ingress is locked down via nginx whitelist-source-range. Pro / Team / Growth feature. */ + private?: boolean; + /** @description Mirror of the top-level 'redeployed' flag — included inside item so a client that reads only item still sees the in-place-vs-fresh branch indicator. True when this row was reused via POST /deploy/new redeploy=true, false on the fresh-deploy path. */ + redeployed?: boolean; + /** @description Wave FIX-J. Count of reminder emails dispatched (0..6). Present when ttl_policy != 'permanent'. */ + reminders_sent?: number; + /** @enum {string} */ + status?: "building" | "deploying" | "healthy" | "failed" | "stopped" | "expired"; + /** Format: uuid */ + team_id?: string; + tier?: string; + /** + * @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 {string} + */ + ttl_policy?: "auto_24h" | "permanent" | "custom"; + /** @description Live HTTPS URL (set once status=healthy) */ + url?: string; + }; + note?: string; + ok?: boolean; + /** @description True when this response served an in-place redeploy (POST /deploy/new redeploy=true matched an existing deployment), false on the fresh-deploy path. Always present so agents have a single response shape across both branches. */ + redeployed?: boolean; + }; + /** @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. */ + DeploymentEvent: { + /** + * Format: date-time + * @description When the worker wrote the autopsy row (RFC3339). + */ + created_at: string; + /** @description k8s event reason or build error text. Empty string when no upstream event was captured. */ + event: string; + /** @description Process exit code when known (137 = SIGKILL, often OOM). null when the failure mode has no exit code (image pull failure, etc.). */ + exit_code: number | null; + /** @description Plain-language likely cause + suggested remedy. Sourced from models.HintForReason; safe to relay verbatim to the user. */ + hint: string; + /** @description Event kind. Today: 'failure_autopsy'. Future kinds may include 'lifecycle'. */ + kind: string; + /** @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). */ + last_lines: string[]; + /** @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. */ + reason: string; + }; + /** @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. */ + DeploymentEventsResponse: { + /** @description Length of the events array. 0 when the deployment has no events yet (healthy / never-failed). */ + count: number; + /** + * Format: uuid + * @description The deployment's primary key UUID. Resolved from the app_id slug in the URL path. + */ + deployment_id: string; + events: components["schemas"]["DeploymentEvent"][]; + ok: boolean; + }; + /** @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. */ + ErrorResponse: { + /** @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). */ + agent_action?: string; + /** + * Format: uri + * @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. + */ + claim_url?: string; + /** @description Stable machine-readable error code (e.g. 'quota_exceeded', 'invalid_token', 'forbidden', 'storage_limit_reached'). Programmatic clients should branch on this. */ + error: string; + /** @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. */ + message: string; + /** + * @description Always false on error responses + * @enum {boolean} + */ + ok: false; + /** @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. */ + request_id?: string; + /** @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. */ + retry_after_seconds: number | null; + /** + * Format: uri + * @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. + */ + upgrade_url?: string; + }; + /** @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. */ + GitHubConnection: { + /** @description Deployment short slug (e.g. '6fffcc21'). */ + app_id: string; + /** @description Tracked branch. Pushes to other branches are ignored at receive time. */ + branch: string; + /** Format: date-time */ + created_at: string; + /** @description GitHub repository in 'owner/repo' form. */ + github_repo: string; + /** + * Format: uuid + * @description Connection id. Doubles as the webhook_id segment of the public receive URL. + */ + id: string; + /** + * Format: int64 + * @description Optional GitHub App installation id. Absent when plain-webhook flow was used. + */ + installation_id?: number; + /** @description Most recent commit SHA we enqueued. Powers idempotency — a duplicate push.event with the same SHA is a no-op. */ + last_commit_sha?: string; + /** + * Format: date-time + * @description Most recent push that triggered a deploy. Absent when no push has arrived yet. + */ + last_deploy_at?: string; + }; + HealthResponse: { + /** @description RFC3339 UTC timestamp when the running binary was built. Falls back to 'dev'. */ + build_time?: string; + /** @description Short git SHA of the running binary (compiled via -ldflags). Falls back to 'dev' for un-instrumented builds. */ + commit_id?: string; + /** @description Total number of migrations recorded as applied in schema_migrations. 0 when migration_status='unknown'. */ + migration_count?: number; + /** + * @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 {string} + */ + migration_status?: "ok" | "unknown"; + /** @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'. */ + migration_version?: string; + ok?: boolean; + service?: string; + /** @description Build version tag from -ldflags. Falls back to 'dev'. */ + version?: string; + }; + /** @description One incident row. The future incident-feed worker will populate these; today the items array is always empty. */ + Incident: { + id: string; + /** + * Format: date-time + * @description Omitted while status != 'resolved'. + */ + resolved_at?: string; + /** @enum {string} */ + severity: "info" | "minor" | "major" | "critical"; + /** Format: date-time */ + started_at: string; + /** @enum {string} */ + status: "investigating" | "identified" | "monitoring" | "resolved"; + summary: string; + title: string; + /** + * Format: uri + * @description Optional link to the public incident write-up. + */ + url?: string; + }; + IncidentsResponse: { + items: components["schemas"]["Incident"][]; + ok: boolean; + /** + * Format: uri + * @description Companion human-readable status page. + */ + status_page?: string; + /** @description Equal to items.length today; the field is reserved for future pagination. */ + total: number; + }; + InvitationResponse: { + /** Format: email */ + email?: string; + /** Format: date-time */ + expires_at?: string; + /** Format: uuid */ + id?: string; + ok?: boolean; + /** @enum {string} */ + role?: "admin" | "developer" | "viewer" | "member"; + /** Format: uuid */ + team_id?: string; + }; + NoSQLProvisionResponse: { + /** + * Format: uri + * @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. + */ + claim_url?: string; + /** @description mongodb:// connection string scoped to a per-token database. Use this from external callers. */ + connection_url?: string; + /** @description True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool. Authenticated provisions only. */ + dedicated?: boolean; + /** @description Resolved environment bucket (defaults to 'development' when omitted). */ + env?: string; + /** @description Present only when env was omitted and defaulted ('default_no_env_specified'). */ + env_override_reason?: string; + /** + * Format: date-time + * @description Anonymous-tier only. RFC3339 24h-TTL expiry. T19 P0-2 (BugHunt 2026-05-20). + */ + expires_at?: string; + /** + * Format: uuid + * @description Resource row id. + */ + id?: string; + /** @description Cluster-internal mongodb:// URL routed via instant-mongo-proxy. Use this when calling from a workload deployed inside the instanode cluster. */ + internal_url?: string; + limits?: { + connections?: number; + expires_in?: string; + storage_mb?: number; + }; + /** @description Human-readable label supplied on the request (or the generated default). */ + name?: string; + note?: string; + ok?: boolean; + tier?: string; + /** Format: uuid */ + token?: string; + /** + * Format: uri + * @description Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow. + */ + upgrade?: string; + /** @description Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions. */ + upgrade_jwt?: string; + /** @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. */ + warning?: string; + }; + OAuthProtectedResourceMetadata: { + authorization_servers?: string[]; + bearer_methods_supported?: "header"[]; + /** @description Canonical URL of this protected resource */ + resource?: string; + resource_documentation?: string; + }; + ProvisionRequest: { + /** + * @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. + * @default development + */ + env: string; + /** @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. */ + name: string; + /** + * Format: uuid + * @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. + */ + parent_resource_id?: string; + }; + QueueProvisionResponse: { + /** + * @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 {string} + */ + auth_mode?: "isolated" | "legacy_open"; + /** + * Format: uri + * @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. + */ + claim_url?: string; + /** @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. */ + connection_url?: string; + /** @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). */ + credentials?: { + auth_mode?: string; + /** @description Pre-rendered .creds blob (combines JWT + NKey). Write to disk and pass path to nats.UserCredentials(). */ + creds_file?: string; + /** + * Format: date-time + * @description Credential expiry. Omitted = long-lived. + */ + expires_at?: string; + /** @description Account public key (A... format). Used by the platform for credential revocation. */ + key_id?: string; + /** @description Signed user JWT scoped to this resource's subject prefix. */ + nats_jwt?: string; + /** @description User NKey seed (SU... format). SECRET — treat like a password. */ + nats_nkey?: string; + }; + /** @description True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool. */ + dedicated?: boolean; + /** @description Resolved environment bucket (defaults to 'development' when omitted). */ + env?: string; + /** @description Present only when env was omitted and defaulted ('default_no_env_specified'). */ + env_override_reason?: string; + /** + * Format: date-time + * @description Anonymous-tier only. RFC3339 24h-TTL expiry. T19 P0-2 (BugHunt 2026-05-20). + */ + expires_at?: string; + /** + * Format: uuid + * @description Resource row id. + */ + id?: string; + /** @description Cluster-internal nats:// URL routed via instant-nats-proxy. Use this when calling from a workload deployed inside the instanode cluster. */ + internal_url?: string; + /** @description Queue storage cap. storage_mb is read from plans.yaml for the resolved tier. */ + limits?: { + /** @description Anonymous-only */ + expires_in?: string; + storage_mb?: number; + }; + /** @description Human-readable label supplied on the request (or the generated default). */ + name?: string; + note?: string; + ok?: boolean; + /** @description The subject namespace this resource is scoped to (e.g. 'tenant_.'). Publish/subscribe under {subject_prefix}* only. */ + subject_prefix?: string; + tier?: string; + /** Format: uuid */ + token?: string; + /** + * Format: uri + * @description Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow. + */ + upgrade?: string; + /** @description Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions. */ + upgrade_jwt?: string; + }; + /** @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). */ + ReadinessResponse: { + checks?: { + /** + * Format: date-time + * @description RFC3339 UTC timestamp of the last probe (may be older than the request if the cache served this response). + */ + last_check_at?: string; + /** @description Last observed error message (only present when status != 'ok'). Scrubbed of credentials. */ + last_error?: string; + /** @description Wall-clock duration of the last probe in milliseconds. */ + latency_ms?: number; + /** @description Stable component identifier (e.g. 'platform_db', 'provisioner_grpc', 'brevo', 'razorpay', 'redis', 'do_spaces', 'river'). */ + name?: string; + /** + * @description Per-component status. Critical components only impact overall=failed; non-critical only impact overall=degraded. + * @enum {string} + */ + status?: "ok" | "degraded" | "failed"; + }[]; + /** @description Short git SHA of the running binary (same value as /healthz.commit_id). */ + commit_id?: string; + /** + * @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 {string} + */ + overall?: "ok" | "degraded" | "failed"; + /** @description Identifier for the service answering the probe (e.g. 'instant-api', 'instant-worker', 'instant-provisioner'). */ + service?: string; + }; + /** @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. */ + ResourceItem: { + /** @description Backing cloud vendor. Present only when known. */ + cloud_vendor?: string; + /** @description Tier connection ceiling. -1 means unlimited. From plans.Registry. */ + connections_limit?: number; + /** @description ISO country code of the resource region. Present only when known. */ + country_code?: string; + /** Format: date-time */ + created_at?: string; + /** @description Environment scope (production / staging / dev / ...) */ + env?: string; + /** + * Format: date-time + * @description Auto-expiry timestamp. Present only for anonymous/TTL'd resources. + */ + expires_at?: string | null; + /** Format: uuid */ + id?: string; + /** @description Caller-supplied resource name. Present only when set. */ + name?: string; + /** + * Format: date-time + * @description When the resource was paused. Present only when paused. + */ + paused_at?: string; + /** @enum {string} */ + resource_type?: "postgres" | "redis" | "mongodb" | "queue" | "storage" | "webhook" | "vector"; + status?: string; + /** @description Current storage usage in bytes (scanner-updated). */ + storage_bytes?: number; + /** @description True when storage_bytes has reached storage_limit_bytes. */ + storage_exceeded?: boolean; + /** @description Tier storage ceiling in bytes (MiB-based). -1 means unlimited. From plans.Registry. */ + storage_limit_bytes?: number; + /** + * Format: uuid + * @description Owning team. Present only for claimed (non-anonymous) resources. + */ + team_id?: string; + tier?: string; + /** Format: uuid */ + token?: string; + }; + ResourceListResponse: { + items?: components["schemas"]["ResourceItem"][]; + ok?: boolean; + total?: number; + }; + /** @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). */ + StackRequest: { + /** @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 } */ + manifest: string; + /** @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. */ + name: string; + } & { + [key: string]: string; + }; + StackResponse: { + /** @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. */ + env?: string; + /** @description Anonymous stacks have a 24h TTL; authenticated stacks return empty. */ + expires_in?: string; + /** @description Optional human-readable label (from manifest.name) */ + name?: string; + note?: string; + ok?: boolean; + services?: { + expose?: boolean; + /** @description Service name from the manifest */ + name?: string; + port?: number; + /** @enum {string} */ + status?: "building" | "deploying" | "healthy" | "failed" | "stopped"; + /** @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://:. */ + url?: string; + }[]; + /** @description Format: stk-<8-char-hex>. Use this for GET /stacks/{slug}. */ + stack_id?: string; + /** + * @description Overall stack status. 'healthy' only when every service is healthy. + * @enum {string} + */ + status?: "building" | "deploying" | "healthy" | "failed" | "stopped"; + tier?: string; + }; + StatusResponse: { + /** + * Format: date-time + * @description Wall-clock at which the underlying aggregation ran. Stable across multiple replays of the same cache entry. + */ + as_of: string; + /** @description Rendered in display order — core services first, then compute, then edge. */ + components: components["schemas"]["ComponentStatus"][]; + /** @description Open incidents at the time of the snapshot. Today this is always empty — the field is reserved for the future incident-feed worker. */ + current_incidents: components["schemas"]["Incident"][]; + /** @description Cache window the server enforces. Matches Cache-Control max-age. */ + freshness_seconds: number; + ok: boolean; + }; + StorageProvisionResponse: { + /** @description Present in credential modes only (shared-master-key / prefix-scoped / prefix-scoped-temporary). Omitted in broker mode. */ + access_key_id?: string; + /** @description Machine-readable hint for an automated caller. Present only when mode=broker; value is 'use_presign_endpoint'. */ + agent_action?: string; + /** @description Human-readable note explaining why broker mode was selected (e.g. 'backend-has-no-prefix-scoping'). Present only when mode=broker. */ + broker_reason?: string; + /** + * Format: uri + * @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. + */ + claim_url?: string; + /** @description Public bucket URL scoped to the per-token prefix */ + connection_url?: string; + /** @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). */ + credentials_note?: string; + /** @description S3-compatible endpoint host (e.g. minio.instant-data.svc.cluster.local:9000 / r2.instanode.dev) */ + endpoint?: string; + /** @description Resolved environment bucket (defaults to 'development' when omitted). */ + env?: string; + /** @description Present only when env was omitted and defaulted ('default_no_env_specified'). */ + env_override_reason?: string; + /** + * Format: date-time + * @description Anonymous-tier only. RFC3339 timestamp at which the resource auto-expires (24h TTL). + */ + expires_at?: string; + /** + * Format: uuid + * @description Resource row id + */ + id?: string; + limits?: { + /** @description Anonymous-only */ + expires_in?: string; + storage_mb?: number; + }; + /** + * @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 {string} + */ + mode?: "shared-master-key" | "prefix-scoped" | "prefix-scoped-temporary" | "broker" | "dedicated-bucket"; + name?: string; + /** @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. */ + note?: string; + /** @description Human-readable explanation of the isolation tradeoff. Present only when mode=broker. */ + note_isolation?: string; + ok?: boolean; + /** @description Object-key prefix all writes must use for isolation */ + prefix?: string; + /** @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. */ + presign_url?: string; + /** @description Shown ONCE — store now; rotation requires re-provisioning. Omitted in broker mode. */ + secret_access_key?: string; + /** @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. */ + session_token?: string; + tier?: string; + /** Format: uuid */ + token?: string; + /** + * Format: uri + * @description Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow. + */ + upgrade?: string; + /** @description Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions. */ + upgrade_jwt?: string; + /** @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. */ + warning?: string; + }; + /** @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. */ + TeamSelf: { + /** + * Format: date-time + * @description When the team row was created. UTC, second precision. + */ + created_at: string; + /** @description Mirror of teams.razorpay_subscription_id IS NOT NULL — true once the team has been wired to a Razorpay subscription. */ + has_active_subscription: boolean; + /** Format: uuid */ + id: string; + /** @description Display name. Empty string when never set. */ + name: string; + /** @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. */ + plan_tier: string; + }; + /** @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. */ + TeamSummaryResourceCounts: { + mongodb?: number; + /** @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. */ + other?: number; + postgres?: number; + queue?: number; + redis?: number; + storage?: number; + /** @description Sum across every bucket (typed + other). */ + total: number; + webhook?: number; + }; + /** @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. */ + TeamSummaryResponse: { + /** + * Format: date-time + * @description When the aggregation was computed. + */ + as_of: string; + /** @description Per-area counts. resources.total is the sum of every typed bucket plus 'other' — saves the dashboard from re-adding. */ + counts: { + /** @description Active deployments. Excludes status IN ('deleted','stopped') — matches the dashboard's 'active deployments' framing. */ + deployments: number; + /** @description Team member count (including the caller). */ + members: number; + resources: components["schemas"]["TeamSummaryResourceCounts"]; + /** @description Total vault entries across every env this team owns. */ + vault_keys: number; + }; + /** @description Cache TTL window in seconds. Today 300 — matches the server-side const and the Cache-Control max-age. */ + freshness_seconds: number; + /** @enum {boolean} */ + ok: true; + /** + * @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 {string} + */ + tier: "anonymous" | "free" | "hobby" | "hobby_plus" | "growth" | "pro" | "team" | "hobby_yearly" | "hobby_plus_yearly" | "growth_yearly" | "pro_yearly" | "team_yearly"; + }; + /** @description Capability row for one tier in the /api/v1/capabilities matrix. Adding a new tier in plans.yaml automatically produces a new row. */ + TierCapabilities: { + /** @description Discount percent of the {tier}_yearly variant vs 12x the monthly. 0 when no yearly variant exists. */ + annual_discount_percent?: number; + backup_restore_enabled?: boolean; + backup_retention_days?: number; + /** @description Per-service concurrent-connection cap. Keys mirror storage_limit_mb. -1 = unlimited. */ + connections_limit: { + [key: string]: number; + }; + /** @description Max number of /deploy/new apps allowed. -1 = unlimited. */ + deployments_apps: number; + /** @description Human-readable name for the tier, e.g. 'Hobby' or 'Pro'. */ + display_name: string; + /** @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. */ + is_terminal_tier: boolean; + manual_backups_per_day?: number; + /** @description True iff price_usd_monthly > 0. Mirrors project policy: no trial — paid tiers are paid from signup. */ + paid_from_day_one: boolean; + /** @description Monthly price in whole USD (cents/100). 0 for free/anonymous tiers. */ + price_usd_monthly: number; + /** @description Task #55: per-service max number of active resources a team may hold. Keys: postgres, vector, redis, mongodb, storage, queue (webhook is request-capped, not count-capped). -1 = unlimited. Enforcement is flag-gated (RESOURCE_COUNT_CAPS_ENABLED) but the cap is always advertised so an agent can plan around it. */ + resource_count_limit?: { + [key: string]: number; + }; + /** @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. */ + rpo_minutes?: number; + /** @description Recovery Time Objective in minutes — the target time to restore service after an incident. 0 means no RTO guarantee for the tier. */ + rto_minutes?: number; + /** @description Per-service storage cap in MB. Keys: postgres, redis, mongodb, queue, storage, webhook, vector. -1 sentinel means 'unlimited'. */ + storage_limit_mb: { + [key: string]: number; + }; + /** @description Canonical tier name (e.g. 'hobby', 'pro'). *_yearly variants are not surfaced; the canonical monthly tier represents the capability bundle. */ + tier: string; + /** + * Format: uri + * @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). + */ + upgrade_url: string | null; + }; + /** @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'. */ + UsageMetric: { + /** + * Format: int64 + * @description Current storage usage in bytes. Present on postgres/redis/mongodb. + */ + bytes?: number; + /** @description Current count. Present on deployments/webhooks/vault/members, and (Task #55) on postgres/redis/mongodb as the active-resource count alongside bytes. */ + count?: number; + /** @description Task #55: per-tier resource-COUNT cap for the byte-metered storage services (postgres/redis/mongodb), where the limit field is unused. -1 = unlimited. Enforcement is flag-gated (RESOURCE_COUNT_CAPS_ENABLED) but the cap is always advertised. */ + count_limit?: number; + /** @description Count cap from plans.yaml. -1 = unlimited. */ + limit?: number; + /** + * Format: int64 + * @description Storage cap in bytes (plans.yaml storage_mb × 1024 × 1024). -1 = unlimited. + */ + limit_bytes?: number; + }; + VaultGetResponse: { + env?: string; + key?: string; + ok?: boolean; + /** @description Decrypted plaintext */ + value?: string; + version?: number; + }; + VaultPutResponse: { + env?: string; + key?: string; + ok?: boolean; + version?: number; + }; + /** @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. */ + VectorProvisionRequest: { + /** + * @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. + * @default 1536 + */ + dimensions: number; + /** + * @description Optional environment scope. Defaults to 'development'. + * @default development + */ + env: string; + /** @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. */ + name?: string; + /** + * Format: uuid + * @description Optional family-link parent (authenticated callers only). See ProvisionRequest. + */ + parent_resource_id?: string; + }; + VectorProvisionResponse: { + /** + * Format: uri + * @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. + */ + claim_url?: string; + /** @description postgres:// connection string with the pgvector extension already installed (CREATE EXTENSION vector ran during provisioning). Use this from external callers. */ + connection_url?: string; + /** @description True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool. Authenticated provisions only. */ + dedicated?: boolean; + /** @description Echo of the requested dimensions hint (defaults to 1536). Informational only — pgvector enforces dimensions per column, not per database. */ + dimensions?: number; + /** @description Resolved environment bucket (defaults to 'development' when omitted). */ + env?: string; + /** @description Present only when env was omitted and defaulted ('default_no_env_specified'). */ + env_override_reason?: string; + /** + * Format: date-time + * @description Anonymous-tier only. RFC3339 24h-TTL expiry. T19 P0-2 (BugHunt 2026-05-20). + */ + expires_at?: string; + /** + * @description Always 'pgvector' for /vector/new. Declared so clients can confirm the extension is present without querying pg_extension. + * @enum {string} + */ + extension?: "pgvector"; + /** + * Format: uuid + * @description Resource row id. + */ + id?: string; + /** @description Cluster-internal postgres:// URL routed via instant-pg-proxy. Use this when calling from a workload deployed inside the instanode cluster. */ + internal_url?: string; + limits?: { + connections?: number; + expires_in?: string; + storage_mb?: number; + }; + /** @description Human-readable label supplied on the request (or the generated default). */ + name?: string; + note?: string; + ok?: boolean; + tier?: string; + /** Format: uuid */ + token?: string; + /** + * Format: uri + * @description Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow. + */ + upgrade?: string; + /** @description Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions. */ + upgrade_jwt?: string; + /** @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. */ + warning?: string; + }; + WebhookProvisionResponse: { + /** + * Format: uri + * @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. + */ + claim_url?: string; + /** @description Resolved environment bucket (defaults to 'development' when omitted). */ + env?: string; + /** @description Present only when env was omitted and defaulted ('default_no_env_specified'). */ + env_override_reason?: string; + /** Format: date-time */ + expires_at?: string; + /** + * Format: uuid + * @description Resource row id. + */ + id?: string; + limits?: { + expires_in?: string; + requests_stored?: number; + }; + /** @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. */ + name?: string; + note?: string; + ok?: boolean; + /** @description Public URL that accepts any HTTP method and stores the payload */ + receive_url?: string; + tier?: string; + /** Format: uuid */ + token?: string; + /** + * Format: uri + * @description Anonymous-tier only. Pre-baked GET /start?t= URL for the dashboard claim flow. + */ + upgrade?: string; + /** @description Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions. */ + upgrade_jwt?: string; + }; + WhoamiResponse: { + /** + * Format: email + * @description Authenticated user's email. Best-effort enrichment from the users table; absent on DB lookup failure. + */ + email?: string; + ok: boolean; + /** + * @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 {string} + */ + plan_tier?: "anonymous" | "free" | "hobby" | "hobby_plus" | "pro" | "team" | "growth"; + /** Format: uuid */ + team_id: string; + /** @description Present only when the team has a non-empty name */ + team_name?: string; + /** + * @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 {string} + */ + tier?: "anonymous" | "free" | "hobby" | "hobby_plus" | "pro" | "team" | "growth"; + /** Format: uuid */ + user_id: string; + }; + }; + responses: { + /** @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. */ + PayloadTooLarge: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": 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. */ + TooManyRequests: { + headers: { + /** @description Seconds the caller should wait before retrying. */ + "Retry-After"?: number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/src/api/index.test.ts b/src/api/index.test.ts index 4d0e5ae..252cbc7 100644 --- a/src/api/index.test.ts +++ b/src/api/index.test.ts @@ -958,6 +958,31 @@ describe('listResources()', () => { const r = await listResources() expect(r.total).toBe(2) }) + + // Wave 1 (contract-drift gate): adaptResource now derives its param type from + // the wire ResourceItem (generated.ts), where EVERY field is optional. This + // exercises the `?? ''` / default fallbacks for id/token/resource_type/tier/ + // status/created_at so a degraded/partial payload (older or partial-outage + // API build) produces a safe Resource rather than undefineds leaking into the + // UI. Pins the fallback behavior the codegen change introduced. + it('fills safe defaults when a wire item omits required-looking fields', async () => { + const m = installFetch() + // A wire item with NONE of id/token/resource_type/tier/status/created_at — + // every value comes from a fallback. + m.mockResolvedValueOnce(jsonResponse({ ok: true, total: 1, items: [{}] })) + const r = await listResources() + expect(r.ok).toBe(true) + const item = r.items[0] + expect(item.id).toBe('') + expect(item.token).toBe('') + expect(item.resource_type).toBe('postgres') // default classification + expect(item.tier).toBe('') + expect(item.status).toBe('') + expect(item.created_at).toBe('') + expect(item.env).toBe('production') + expect(item.storage_bytes).toBe(0) + expect(item.storage_exceeded).toBe(false) + }) }) describe('deleteResource()', () => { diff --git a/src/api/index.ts b/src/api/index.ts index 3af677f..3d626f5 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -8,6 +8,7 @@ // real error banner instead of lying with mock data. import type { + Tier, Resource, ResourceType, DashboardStack, StackStatus, DashboardDeployment, DeploymentStatus, DeploymentFailure, DashboardTeam, BillingDetails, Invoice, @@ -15,6 +16,11 @@ import type { AdminCustomerListResponse, AdminCustomerDetailResponse, AdminIssuePromoInput, AdminIssuePromoResponse, AdminSetTierInput, AdminSetTierResponse, + // Wire types DERIVED from the OpenAPI snapshot (generated.ts via gen:api-types). + // Consuming these here is what makes an api field rename fail `tsc` — see the + // contract-drift gate header in types.ts (Wave 1). + WireAuthMe, WireResourceItem, WireResourceListResponse, + WireBillingState, WireDeployItem, } from './types' export * from './types' @@ -244,28 +250,15 @@ async function call( // removed on 2026-05-14 per policy memory project_no_trial_pay_day_one.md. // The platform has no trial period — hobby/pro/team are paid from day one. export async function fetchMe(): Promise { - type AgentMe = { - ok: boolean - user_id: string - team_id: string - email: string - tier: string - /** A/B-test bucket per registered experiment, e.g. - * `{ upgrade_button: "urgent" }`. Older API builds omit this - * field entirely — callers must treat undefined as "no - * experiment, render control variant". */ - experiments?: Record - /** Track A — server-authoritative platform-admin flag. The - * dashboard surfaces the `/app/admin/customers` console only when - * this is `true`. Absent on older API builds → treat as `false`. */ - is_platform_admin?: boolean - /** Unguessable URL prefix for the admin customer-management surface. - * Sent by the API only when (a) the caller is on the ADMIN_EMAILS - * allowlist AND (b) the deploy has ADMIN_PATH_PREFIX configured. - * Absent for every other caller / configuration. Treat as "no admin - * surface available" when undefined or empty. */ - admin_path_prefix?: string - } + // AgentMe is the WIRE shape of GET /auth/me — DERIVED from the OpenAPI + // snapshot (WireAuthMe = components['schemas']['AuthMeResponse']). It used to + // be hand-typed here; deriving it means an api rename/removal of any /auth/me + // field (e.g. dropping `tier`, the login-break class) fails `tsc` right here + // at the read sites below. The wire schema marks every field optional (no + // `required` array on the response), so the reads below use nullish guards — + // the server sends user_id/team_id/email/tier on every real response, but a + // partial/older build that omits one must degrade, not throw. + type AgentMe = WireAuthMe // No try/catch — errors propagate. The previous fixture fallback masked // backend outages by serving the `aanya@acme.dev` mock identity, which // led to chrome lying ("acme-corp", "aanya@acme.dev") instead of @@ -277,7 +270,16 @@ export async function fetchMe(): Promise { // Derive a stable team slug from the email's local part — the only // human-readable identity we have until a real team table exposes a slug. const localPart = me.email?.split('@')[0] ?? '' - const slug = localPart.toLowerCase().replace(/[^a-z0-9-]/g, '-') || me.team_id.slice(0, 8) + // `?? ''` guards: WireAuthMe marks team_id/user_id/email/tier optional (the + // wire schema has no `required` array), so the derived type is `string | + // undefined`. Real responses always carry them; an omission degrades to an + // empty identity rather than throwing on `.slice` / failing the non-optional + // User/DashboardTeam field types. Runtime is unchanged for well-formed payloads. + const teamId = me.team_id ?? '' + const userId = me.user_id ?? '' + const email = me.email ?? '' + const tier = (me.tier ?? '') as Tier + const slug = localPart.toLowerCase().replace(/[^a-z0-9-]/g, '-') || teamId.slice(0, 8) // Stash the admin path prefix in a module-local var so the admin URL // builders below can mint `/api/v1/${prefix}/customers/...` requests // without forcing every caller to plumb it through manually. The prefix @@ -285,19 +287,19 @@ export async function fetchMe(): Promise { setAdminPathPrefix(me.admin_path_prefix ?? '') return { user: { - id: me.user_id, - email: me.email, - team_id: me.team_id, - tier: me.tier as any, + id: userId, + email, + team_id: teamId, + tier, created_at: '', }, team: { - id: me.team_id, + id: teamId, name: localPart || 'workspace', slug, - owner_id: me.user_id, + owner_id: userId, member_count: 1, - tier: me.tier as any, + tier, created_at: '', }, experiments: me.experiments, @@ -610,8 +612,15 @@ export async function inviteMember(body: { email: string; role: string }): Promi } // ─── Resources (LIVE) ─────────────────────────────────────────────────── -type ResourceListResp = { ok: boolean; items: any[]; total: number } -type ResourceGetResp = { ok: boolean; item: any } +// Resource wire envelopes — DERIVED from the OpenAPI snapshot. The list +// envelope is the generated ResourceListResponse; the detail `item` is a +// WireResourceItem. adaptResource() (below) reads these, so an api rename of +// e.g. storage_bytes → storageBytes fails `tsc` at the adapter read site. +// connection_url is NOT on the wire ResourceItem (it comes from the separate +// /credentials endpoint and is spliced in), so the detail item is widened to +// allow it. +type ResourceListResp = WireResourceListResponse +type ResourceGetResp = { ok?: boolean; item?: WireResourceItem & { connection_url?: string } } // CREDENTIALED_RESOURCE_TYPES — the resource types whose // GET /api/v1/resources/:id/credentials endpoint returns a usable @@ -634,13 +643,27 @@ export const CREDENTIALED_RESOURCE_TYPES: ReadonlySet = new Set(`/api/v1/resources/${id}/credentials`) connection_url = c.connection_url @@ -905,49 +932,24 @@ export async function listStacks(): Promise<{ ok: true; items: DashboardStack[]; // Status mapping: the server emits 'healthy' for a live deploy, which the // dashboard's shared StatusPill renders as 'running' (matching stacks). // We normalise here so consumer code doesn't need to special-case it. -type DeploymentRespItem = { - id?: string - token?: string - app_id?: string - url?: string - port?: number - tier?: string - status?: string - // Server returns env as a map of env_vars (legacy alias). New callers - // should also accept env_vars for forward compat with the spec. +// Deployment wire row — the DOCUMENTED fields are DERIVED from the OpenAPI +// snapshot (WireDeployItem = components['schemas']['DeployItem']). adaptDeployment +// (below) reads them, so an api rename of app_id/status/environment/ttl_policy/ +// failure.* fails `tsc` at the adapter site. +// +// WireDeployItem types `env` as a string map (the masked env_vars). The +// adapter, however, historically tolerated `env` being EITHER a map or a +// string, and reads two UI-side conveniences the wire schema does NOT carry: +// - env_vars — the adapter's preferred name for the env map. +// - last_deploy_at — UI alias; adapter falls back to updated_at. +// - build_duration_s — reserved; not on the wire yet. +// Those three are intersected on as optional. The `env` widening keeps the +// adapter's defensive both-shapes handling. Everything else comes from the spec. +type DeploymentRespItem = Omit & { env?: Record | string env_vars?: Record - // Env scope (production / staging / dev / ...). - environment?: string - created_at?: string - updated_at?: string last_deploy_at?: string build_duration_s?: number - resource_id?: string - name?: string - // Track B (private deploys): server flags + IP allow-list. Older API - // builds omit these fields entirely — the adapter treats them as - // `private=false` and `allowed_ips=[]` so the dashboard never lies about - // privacy state when the backend hasn't shipped yet. - private?: boolean - allowed_ips?: string[] - // Wave FIX-J TTL fields (migration 045 + handlers/deploy_ttl.go). - ttl_policy?: string - expires_at?: string - reminders_sent?: number - make_permanent_url?: string - extend_ttl_url?: string - // Phase 0 Failure Autopsy — present only on failed deploys where the - // backend has captured diagnostics. Absent on healthy / building / stopped - // deploys, and on failed deploys where autopsy is still in flight. - failure?: { - reason?: string - exit_code?: number | null - event?: string - last_lines?: string[] - hint?: string - occurred_at?: string - } } type DeploymentsListResp = { @@ -1029,7 +1031,14 @@ function adaptDeployment(d: DeploymentRespItem): DashboardDeployment { } function adaptFailure( - raw: DeploymentRespItem['failure'], + // LATENT DRIFT (surfaced by the codegen gate): the UI's DeploymentFailure has + // `exit_code`, but the wire DeployItem.failure schema does NOT carry it (only + // GET /deployments/:id/events does — see CLAUDE.md rule 27). So `exit_code` is + // intersected as optional here; at runtime it was always undefined on this + // path → coerced to null below, identical to before. Tracked in the report as + // a spec/UI gap to reconcile (add exit_code to DeployItem, or drop it from the + // detail panel and read it only from the events surface). + raw: (WireDeployItem['failure'] & { exit_code?: number | null }) | undefined, ): DeploymentFailure | undefined { if (!raw) return undefined // Both `reason` and `hint` are required for the panel to render @@ -1753,24 +1762,17 @@ export async function deleteCustomDomain(stackSlug: string, id: string): Promise // // cancelSubscription — LIVE. POST /api/v1/billing/cancel. -type BillingStateResp = { - ok: boolean - tier: string - subscription_status?: 'none' | 'active' | 'cancelled' - next_renewal_at?: string | null - amount_inr?: number | null - payment_method?: { - type: 'card' | 'upi' | 'netbanking' | 'wallet' - brand?: string - last4?: string - vpa?: string - } | null - billing_email?: string - razorpay_subscription_id?: string | null - razorpay_customer_id?: string | null - // Explicit "is Razorpay configured in this environment" flag. Older API - // builds (GET /api/v1/billing) omit it — see mapBillingState for how an - // absent value is resolved. +// Billing wire shape — DERIVED from the OpenAPI snapshot (WireBillingState = +// components['schemas']['BillingStateResponse']) so an api rename of e.g. +// `tier` or `subscription_status` fails `tsc` at mapBillingState/fetchBilling. +// +// `razorpay_configured` is intersected in: it is NOT in the snapshot schema +// today (the api returns it on /api/v1/billing but it isn't documented in +// openapi.go — a latent spec gap, noted in the report). Keeping it as a local +// intersection preserves the fail-closed default logic in mapBillingState while +// still deriving every documented field from the spec. When openapi.go adds the +// field, drop the intersection so it too is gated. +type BillingStateResp = WireBillingState & { razorpay_configured?: boolean } @@ -1799,8 +1801,11 @@ function mapBillingState(r: BillingStateResp): BillingDetails { // ("contact support to upgrade") beats a button that 502s. razorpay_configured: r.razorpay_configured ?? false, subscription_status: r.subscription_status, - payment_last4: r.payment_method?.last4, - payment_network: r.payment_method?.brand, + // `?? undefined`: the derived BillingPaymentMethod types last4/brand as + // `string | null`, but BillingDetails wants `string | undefined`. Coerce + // null → undefined so a "no card on file" payload renders "—" as before. + payment_last4: r.payment_method?.last4 ?? undefined, + payment_network: r.payment_method?.brand ?? undefined, cancel_at_period_end: false, } } diff --git a/src/api/types.ts b/src/api/types.ts index 48d2458..54ec4e7 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -2,6 +2,42 @@ // Types — mirror the agent API JSON shapes the dashboard consumes. // Source of truth: /InstaNode/api/internal/handlers/* // ------------------------------------------------------------------ +// +// CONTRACT-DRIFT GATE (Wave 1 — docs/ci/01-CI-INTEGRATION-DESIGN.md): +// The *wire* shapes (what the api actually sends on the JSON) are NO LONGER +// hand-mirrored. They are DERIVED from `generated.ts`, which is generated by +// `npm run gen:api-types` from the api's committed openapi.snapshot.json. The +// `Wire*` aliases below are the canonical wire-response types; the adapters in +// index.ts map them into the richer Dashboard* shapes the UI components read. +// +// Why this matters: if the api renames/removes/retypes a field in the OpenAPI +// spec, regenerating generated.ts changes the derived Wire* type, and `tsc` +// fails at every site in index.ts that reads the old field. That turns a +// contract drift (the class that broke login) from a silent runtime break into +// a compile-time failure caught in `npm run gate`. +// +// The hand-maintained Dashboard*/User/etc. types below remain hand-shaped on +// purpose — they are the UI's internal vocabulary (e.g. { user, team } adapted +// from a flat /auth/me payload), not the wire. The wire→UI mapping is the seam. +// +// Conversion is incremental: the highest-value wire shapes (auth/me, resources, +// billing, deployments — the ones that broke before) are derived today. See the +// TODO at the bottom of this file for the remaining wire types still hand-typed. +import type { components } from './generated' + +// ─── Wire types — DERIVED from the OpenAPI snapshot (do not hand-edit) ─────── +type Schemas = components['schemas'] + +/** GET /auth/me wire payload. Flat shape; fetchMe() adapts it into AuthMeResponse. */ +export type WireAuthMe = Schemas['AuthMeResponse'] +/** One row of GET /api/v1/resources / the `item` of GET /api/v1/resources/:id. */ +export type WireResourceItem = Schemas['ResourceItem'] +/** GET /api/v1/resources list envelope. */ +export type WireResourceListResponse = Schemas['ResourceListResponse'] +/** GET /api/v1/billing wire payload; mapBillingState() adapts it. */ +export type WireBillingState = Schemas['BillingStateResponse'] +/** One deployment row from GET /api/v1/deployments / :id; adaptDeployment() maps it. */ +export type WireDeployItem = Schemas['DeployItem'] export type Tier = 'anonymous' | 'free' | 'hobby' | 'hobby_plus' | 'pro' | 'team' | 'growth' // Role: the server-side enum is {owner, admin, developer, viewer, member}. @@ -484,3 +520,32 @@ export interface AdminSetTierResponse { ok: true team: DashboardTeam } + +// ─── Wire-codegen conversion TODO (Wave 1 follow-up) ───────────────────────── +// +// Converted to derive from generated.ts in this PR (the fields that broke +// before): WireAuthMe, WireResourceItem, WireResourceListResponse, +// WireBillingState, WireDeployItem (consumed in index.ts at fetchMe, +// listResources/getResource, fetchBilling, listDeployments/getDeployment). +// +// Still hand-typed in index.ts — derive in follow-up PRs (same Schemas[...] +// pattern; each is wire-faithful so the swap is mechanical): +// - StacksListResp / StackResponse → Schemas['StackResponse'] + list envelope +// - InvoiceWire → (GET /api/v1/billing/invoices — invoice row +// is inlined in the path, not a named schema; +// add a named InvoiceItem schema in openapi.go +// first, then derive) +// - TeamSelf / PatchResp (team) → Schemas['TeamSelf'] +// - TeamSummary counts → Schemas['TeamSummaryResponse'] / ...ResourceCounts +// - VaultGetResponse / VaultPutResponse → Schemas['VaultGetResponse'] / ['VaultPutResponse'] +// - WhoamiResponse → Schemas['WhoamiResponse'] +// - BillingUsageResponse → Schemas['BillingUsageResponse'] +// - DeploymentEventsResponse → Schemas['DeploymentEventsResponse'] +// +// Gate BLIND SPOTS (no web consumer today → no tsc bite even though specced): +// - /api/v1/capabilities (CapabilitiesResponse/TierCapabilities): instanode-web +// renders a STATIC PricingGrid, it does not fetch /capabilities. Add a +// consumer (or the codegen alias won't be referenced) to gate it. +// - Admin* customer-console types: the Track-A admin surface is specced server- +// side but the named schemas (AdminCustomer*) are not in the snapshot yet; +// keep hand-typed until openapi.go documents /api/v1/admin/customers. diff --git a/vite.config.ts b/vite.config.ts index b5e277b..b528beb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vite'; +import { coverageConfigDefaults } from 'vitest/config'; import react from '@vitejs/plugin-react'; // AGENT_API_URL — the agent-facing instanode.dev API. @@ -68,5 +69,16 @@ export default defineConfig({ include: ['src/**/*.{test,spec}.{ts,tsx}', 'e2e/**/*.test.ts'], exclude: ['e2e/**/*.spec.ts', 'node_modules/**', 'dist/**'], passWithNoTests: true, + coverage: { + // src/api/generated.ts is GENERATED by `npm run gen:api-types` + // (openapi-typescript) — it is pure type declarations with no runtime + // code, and is regenerated/up-to-date-gated separately + // (gen:api-types:check). Exclude it from coverage so the org 100%-patch + // gate (diff-cover, see .github/workflows/coverage.yml) never demands + // tests for machine-generated types. Same rationale as excluding any + // codegen artifact. The v8 provider's default excludes are preserved by + // spreading coverageConfigDefaults.exclude. + exclude: [...coverageConfigDefaults.exclude, 'src/api/generated.ts'], + }, }, });