diff --git a/docs/stacks.md b/docs/stacks.md index e3ab6f5..e6efd91 100644 --- a/docs/stacks.md +++ b/docs/stacks.md @@ -19,11 +19,16 @@ digits, spaces, underscores and hyphens after). Omitting it returns curl -X POST https://api.instanode.dev/stacks/new \ -H "Authorization: Bearer " \ -F "name=shop-stack" \ - -F "manifest=@instant.yaml" \ + -F "manifest= **`manifest` is a form VALUE, not a file upload.** Use `<` (read the file's contents into the +> field), not `@` (attach it as a file part). The handler reads `manifest` from the multipart +> *values*, so `-F "manifest=@instant.yaml"` lands in the file part and returns +> `400 missing_manifest`. The per-service tarball fields (`api=@…`, `web=@…`) DO use `@`. + ``` services: api: diff --git a/llms.txt b/llms.txt index bad4a78..824f01b 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # instanode.dev -> Zero-friction developer infrastructure for AI agents. Provision real Postgres, Redis, MongoDB, NATS, S3-compatible object storage (DigitalOcean Spaces), webhooks, and container deploys via single HTTP calls — no signup, no API key, no Docker, no cloud account. The first 24 hours are anonymous; claim a resource to keep it past then ($9/mo). +> Zero-friction developer infrastructure for AI agents. Provision real Postgres, Redis, MongoDB, NATS, S3-compatible object storage (Cloudflare R2), webhooks, and container deploys via single HTTP calls — no signup, no API key, no Docker, no cloud account. The first 24 hours are anonymous; claim a resource to keep it past then ($9/mo). This file follows the llms.txt convention (https://llmstxt.org). Every HTML route on `https://instanode.dev` has a parallel `.md` mirror at the same path with a `.md` suffix — e.g. `/use-cases/foo` is served as HTML and `/use-cases/foo.md` is the same content in plain markdown. The aggregated full text of every page is at [/llms-full.txt](https://instanode.dev/llms-full.txt) for one-shot consumption. @@ -21,6 +21,8 @@ All accept `POST` against `https://api.instanode.dev`. No authentication header Every provisioning endpoint — `/db/new`, `/vector/new`, `/cache/new`, `/nosql/new`, `/queue/new`, `/storage/new`, `/webhook/new`, `/deploy/new`, `/stacks/new` — **requires** a `name` on the request. It is the human-readable label shown in the dashboard and in `GET /api/v1/resources`. An empty JSON body is no longer accepted. - JSON-body endpoints (`/db/new`, `/vector/new`, `/cache/new`, `/nosql/new`, `/queue/new`, `/storage/new`, `/webhook/new`, `/stacks/new`) take `name` as a JSON string field. + +**Redis keys must carry the `key_prefix` from the provisioning response.** `/cache/new` returns `key_prefix` (`":"`); the tenant's ACL user is scoped to it, so a bare `SET mykey` returns the native Redis error `NOPERM No permissions to access a key` — not a platform JSON envelope, so there is no `agent_action` to recover from. Prefix every key: `SET mykey`. - `/deploy/new` is multipart — pass `name` as a form field (`-F "name=..."`). - **Validation:** 1–64 characters, must match `^[A-Za-z0-9][A-Za-z0-9 _-]*$` — start with a letter or digit; remaining characters may be letters, digits, spaces, underscores, or hyphens. - Omitting `name` returns `400 {"error":"name_required"}`. @@ -33,7 +35,7 @@ Pick a descriptive name per resource (e.g. `"prod-db"`, `"sessions-cache"`, `"ev - **`POST /cache/new`** — Redis. Requires `name`. Per-token ACL'd user + namespaced keyspace. Returns `connection_url` in the form `redis://:PASS@HOST:PORT/DB`. - **`POST /nosql/new`** — MongoDB. Requires `name`. Per-token user scoped to a single database. Returns a `mongodb://...` connection URL. The per-token connection budget is documented in the response `limits.connections` field (e.g. anonymous = 2). The underlying shared-tenant pod admits up to 20 simultaneous connections across all tokens, so plan agents to stay well below their per-token allocation under burst. - **`POST /queue/new`** — NATS JetStream. Requires `name`. Returns `connection_url` (`nats://host:4222`) plus a `credentials` object with per-tenant NATS account creds: `credentials.nats_jwt`, `credentials.nats_nkey`, and a pre-rendered `credentials.creds_file` blob. Pass `(nats_jwt, nats_nkey)` to `nats.UserJWTAndSeed()` or write `creds_file` to disk and use `nats.UserCredentials(path)`. Each tenant gets its own NATS account — JetStream streams, subjects, and pub/sub are isolated at the server. `subject_prefix` in the response names the subject namespace this resource is scoped to. The response also includes `auth_mode` ("isolated" or "legacy_open" for grandfathered pre-cutover rows). Durable streams, request/reply, pub/sub. -- **`POST /storage/new`** — S3-compatible bucket prefix backed by DigitalOcean Spaces (`nyc3`). Requires `name`. Returns `connection_url` (`https://s3.instanode.dev/instant-shared//`) plus `endpoint`, `prefix`, and a `mode` field that names the isolation level the tenant landed on. **Today, on DO Spaces, every new tenant (all tiers) lands in `broker` mode**: NO long-lived credential is returned — the response OMITS `access_key_id`/`secret_access_key` and instead carries `presign_url` + `agent_action:"use_presign_endpoint"`. Call `POST /storage/:token/presign` for short-lived (≤1h) signed S3 URLs scoped to your `prefix/*`. The other modes are not currently issued to new tenants: `shared-master-key` (legacy DO Spaces rows only — every tenant held the master key, prefix-by-convention), `prefix-scoped` (backend IAM enforces `s3:prefix` against `/*` — R2/S3/MinIO target), `prefix-scoped-temporary` (same but credentials expire — STS). Anonymous-tier objects are auto-deleted at 24h by a bucket lifecycle rule. See [/use-cases/screenshot-evidence-archive.md](https://instanode.dev/use-cases/screenshot-evidence-archive.md) for a worked example. +- **`POST /storage/new`** — S3-compatible object storage backed by **Cloudflare R2**. Requires `name`. Returns `connection_url`, `endpoint`, `prefix`, and a `mode` field naming the isolation level the tenant landed on. **Today every new tenant (all tiers) lands in `broker` mode**: NO long-lived credential is returned — the response OMITS `access_key_id`/`secret_access_key` and carries `agent_action:"use_presign_endpoint"`. Call `POST /storage/:token/presign` with `{"key":"","operation":"PUT"|"GET"|"HEAD"}` for a short-lived (≤1h) signed URL scoped to your `prefix/*`; `operation` is required and must be one of those three. The other modes are not currently issued: `shared-master-key` (legacy — every tenant held the master key), `prefix-scoped` and `prefix-scoped-temporary` (real per-tenant R2 tokens; target state, needs the account-id plumbing in the api). Anonymous-tier objects are deleted at expiry by the TTL reaper, which removes everything under the tenant prefix — **not** by a bucket lifecycle rule. There is deliberately no bucket-wide expiry: the tenant prefix carries no tier marker, so a bucket rule cannot tell an anonymous object from a paying customer's, and a previous whole-bucket rule silently deleted every backup within 24h. - **`POST /webhook/new`** — Public receive URL that captures any HTTP method. Requires `name`. Returns `receive_url`. Inspect received payloads at `GET https://api.instanode.dev/api/v1/webhooks/{token}/requests`. - **`POST /storage/{token}/presign`** — Mint a short-lived (≤1h) signed S3 URL for a storage resource that landed in `mode="broker"` (no long-lived credential issued by `/storage/new` — DO Spaces today for new tenants). Body: `{"operation": "PUT"|"GET", "key": "", "expires_in": }`. Returns `{ok, url, expires_at}`. Signed by the platform master key but constrained to the resource's own `prefix/*`, so a leaked URL cannot escape the tenant boundary. Rate-limited per token. Don't use this when the `/storage/new` response carried `(access_key_id, secret_access_key)` — go direct to S3 in that case. - **`POST /webhooks/brevo/:secret`** — Brevo delivery webhook receiver (internal — Brevo's transactional pipeline POSTs here for every delivery event). Authentication is by URL token: the `{secret}` path segment is constant-time-compared against the platform's `BREVO_WEBHOOK_SECRET`. Handled events: `delivered`, `soft_bounce`, `hard_bounce`, `blocked`, `complaint`, `deferred`, `unsubscribed`, `error`. The handler overwrites the matching `forwarder_sent` row's `classification` with the real Brevo outcome and stamps `delivered_at` on `delivered` only. Unknown messageIds return `200 {"matched":false}` (Brevo retries on 5xx — orphan events must not amplify retries). Unhandled event types (`click`, `open`, `request`) return `200 {"skipped":true}`. This is the truth surface for "did the user receive the email" — the worker's 201 from Brevo's API only means the relay queued the message; `forwarder_sent.classification` (set by this webhook) is the actual delivery outcome. @@ -95,7 +97,9 @@ curl -X POST https://api.instanode.dev/claim \ **`token` is the canonical request field (since 2026-05-20).** The legacy `jwt` field is still accepted as a deprecated alias for backward compatibility with the dashboard, sdk-go, mcp, and existing curl recipes — when both are present, `token` wins. The OpenAPI spec marks `jwt` as `deprecated: true`. -The `email` field must parse as a valid RFC 5322 address (validated via Go `mail.ParseAddress` + 254-char RFC 5321 §4.5.3.1.3 cap + dotted-domain + no-inner-whitespace gates, since 2026-05-20). A non-email string returns `400 {"error":"invalid_email_format"}` — the claim no longer mints users with unreachable addresses. A magic link arrives by email. Clicking it sets a session cookie that owns every resource attached to your network fingerprint. Tier starts at Free (24h TTL, same limits as anonymous) — claiming gives you an account, not durability. Resources still expire at 24h until the team upgrades to a paid tier (Hobby $9/mo or above) in the dashboard. +The `email` field must parse as a valid RFC 5322 address (validated via Go `mail.ParseAddress` + 254-char RFC 5321 §4.5.3.1.3 cap + dotted-domain + no-inner-whitespace gates, since 2026-05-20). A non-email string returns `400 {"error":"invalid_email_format"}` — the claim no longer mints users with unreachable addresses. + +**`POST /claim` returns a `session_token` in its 201 body, usable immediately as `Authorization: Bearer ` — no email round-trip.** This is the path an unattended agent should use. The field is `session_token` (not `session_jwt`, `jwt` or `api_token`). A magic link is also sent by email for humans who want a browser session. Clicking it sets a session cookie that owns every resource attached to your network fingerprint. Tier starts at Free (24h TTL, same limits as anonymous) — claiming gives you an account, not durability. Resources still expire at 24h until the team upgrades to a paid tier (Hobby $9/mo or above) in the dashboard. ## Tiers @@ -289,6 +293,72 @@ Full guide: [https://instanode.dev/docs#troubleshooting-deploys](https://instano Every error envelope includes an `agent_action` string when recovery is possible — surface it to the user verbatim. The `retry` field (seconds) signals the server-suggested back-off when present. +## Resource health-checks and live connectivity testing + +After provisioning, an agent should verify a resource is reachable before writing application code that depends on it. Use these patterns: + +### Checking provisioned resource status +- **`GET /api/v1/resources`** — list all resources for the authenticated team. Returns `{ok, resources: [{id, token, kind, name, tier, status, environment, created_at, expires_at}]}`. The `status` field is one of `active`, `expired`, `deleted`, or `error`. Filter by `?kind=postgres` to scope to a service type. +- **`GET /api/v1/resources/:token`** — get a single resource by its provision token. Returns the same shape as a single element from the list, plus `connection_url` (decrypted for the calling team) and `limits` (tier-specific maximums). +- **`status:"active"`** is the only signal that the resource is live and provisioned — `error` means the provisioner returned a failure during initial setup (rare; usually a transient backend issue; re-provision with a new name). + +### Verifying connectivity without credentials in source code +When the agent's goal is to *confirm* a database is reachable before handing off to the user: + +```bash +# Quick Postgres reachability check +PGPASSWORD="" psql "" -c "SELECT 1;" 2>&1 + +# Quick Redis reachability check +redis-cli -u "" PING + +# Quick MongoDB reachability check +mongosh "" --eval "db.runCommand({ping:1})" --quiet +``` + +The agent should only emit `connection_url` to the user after confirming `SELECT 1` returns `1` (or equivalent). A non-`active` status or a failed ping means re-provision — call the same endpoint again with a new `name`; the failed resource token is inert. + +### TTL awareness +Every response includes `expires_at` (ISO 8601, null for permanent). An anonymous resource expires 24h after creation. The agent should surface `expires_at` to the user so they can claim the resource before the window closes. The claim URL is in the original provision response as `upgrade`. + +## Rate limiting, quota exhaustion, and recovery patterns + +InstaNode enforces two distinct throttle layers. Agents must handle both without user intervention. + +### Anonymous fingerprint rate limit (pre-auth) +Unauthenticated requests are fingerprinted by IP subnet + ASN. The daily cap is **5 new provisions per fingerprint**. On the 6th attempt within 24 hours, the API returns the *same* token as the most recent same-kind provision for that fingerprint — not a 429 — so the agent receives a valid (if duplicate) resource rather than an error. If distinct resources are needed, the user must claim (authenticate) first. + +### Quota exhaustion (authenticated, over-tier) +When a team's provisioned resource count reaches the tier limit, `POST /db/new` (or any provision endpoint) returns: + +```json +{ + "ok": false, + "error": "quota_exceeded", + "agent_action": "Upgrade to Pro at https://instanode.dev/pricing to provision more databases.", + "upgrade": "https://instanode.dev/pricing" +} +``` + +HTTP status is **402 Payment Required**. The agent should surface `agent_action` verbatim to the user and stop retrying — quota exhaustion is deterministic, not transient. + +### Rate limiting on write endpoints (429) +Heavy-burst provisioning (>20 requests/minute to provisioning endpoints) triggers a 429 with a `Retry-After` header (seconds) and `{"error":"rate_limited","retry":}` in the body. Back off by the `retry` value before retrying. Do not retry immediately — the server-side sliding window is per-IP and does not reset on connection close. + +### Error shapes reference + +| HTTP | `error` field | Transient? | Agent action | +|---|---|---|---| +| 402 | `quota_exceeded` | No | Surface `agent_action` + stop | +| 402 | `tier_required` | No | Surface upgrade link | +| 429 | `rate_limited` | Yes | Wait `retry` seconds | +| 503 | `provisioner_unavailable` | Yes | Retry with 5s back-off, max 3× | +| 409 | `name_taken` | No | Choose a different `name` | +| 400 | `name_required` | No | Add `name` field | +| 400 | `invalid_name` | No | Fix name: `^[A-Za-z0-9][A-Za-z0-9 _-]*$` | + +The `agent_action` string is always present on 402 responses and is human-readable. Print it directly into any user-facing message. + ## How to use this file If you're an LLM helping a user build something, you can: