From 8ad2d58ea31296ba3305d4ba8fb80ade73fc1fac Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Mon, 6 Jul 2026 11:30:00 +0530 Subject: [PATCH 1/3] docs(resources): document resource health-check and live connectivity testing patterns --- llms.txt | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/llms.txt b/llms.txt index bad4a78..5d3283c 100644 --- a/llms.txt +++ b/llms.txt @@ -289,6 +289,34 @@ 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`. + ## How to use this file If you're an LLM helping a user build something, you can: From 01dce73bc9bfcb68d1081a25ef1b682e483336d9 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Tue, 7 Jul 2026 10:15:00 +0530 Subject: [PATCH 2/3] docs(quota): document rate limiting, quota exhaustion responses, and agent recovery patterns --- llms.txt | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/llms.txt b/llms.txt index 5d3283c..91786f2 100644 --- a/llms.txt +++ b/llms.txt @@ -317,6 +317,44 @@ The agent should only emit `connection_url` to the user after confirming `SELECT ### 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: From 7f6208550de62778027b63a1d60d04ba1a4b8143 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 13 Aug 2026 12:04:26 +0530 Subject: [PATCH 3/3] docs: correct four statements the Azure rebuild made false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four were found by agents dogfooding the published docs against the live platform, and each one cost a real run time. 1. STORAGE BACKEND. llms.txt described DigitalOcean Spaces (nyc3) and a connection_url of https://s3.instanode.dev/... Object storage is Cloudflare R2 now, and s3.instanode.dev still resolves to the retired DO load balancer, so anything built from that host is dead. Also documented the presign contract properly: `operation` is REQUIRED and must be GET/PUT/HEAD — a caller guessing `method` gets 400 invalid_operation. Removed the claim that "anonymous-tier objects are auto-deleted at 24h by a bucket lifecycle rule". There is deliberately NO bucket-wide rule: the tenant prefix is a bare resource-token UUID with no tier marker, so a bucket rule cannot distinguish an anonymous object from a paying customer's — and a previous whole-bucket rule silently deleted every backup within 24h. Expiry is enforced by the TTL reaper deleting under the tenant prefix. 2. STACK MANIFEST SYNTAX (docs/stacks.md). The published command used `-F "manifest=@instant.yaml"`, which cannot work: the handler reads manifest from the multipart VALUES, so an @-upload lands in the file part and returns 400 missing_manifest. Corrected to `<` with a note explaining why the per-service tarball fields still use `@`. 3. CLAIM RETURNS A USABLE SESSION. `POST /claim`'s 201 carries `session_token`, usable immediately as a Bearer token with no email round-trip — but that appeared ONLY in the raw OpenAPI schema. Both agent-facing docs described only the magic-link flow, which cannot complete today (no mail provider), so per the docs an unattended agent could never obtain a session. Named the exact field, since session_jwt / jwt / api_token are all wrong guesses. 4. REDIS KEY PREFIX. /cache/new returns `key_prefix` and the tenant ACL is scoped to it, so the natural first attempt — a bare `SET mykey` — fails with the native `NOPERM No permissions to access a key`. That is a raw Redis error, not a platform envelope, so it carries no agent_action to recover from. Now stated explicitly. NOT changed here, deliberately: the claim section still says a session "owns every resource attached to your network fingerprint". That is accurate today and is precisely the P0 being fixed — claiming sweeps every unclaimed resource sharing a /24+ASN bucket, so two strangers behind one NAT means whoever claims first inherits the other's live database. Rewriting it before the fix ships would make the docs wrong in the other direction; it lands with that PR so behaviour and documentation change together. Co-Authored-By: Claude Opus 5 (1M context) --- docs/stacks.md | 7 ++++++- llms.txt | 10 +++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) 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 91786f2..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