diff --git a/docs/limits.md b/docs/limits.md index fe3a866..42bf088 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -8,7 +8,7 @@ order: 7 | Anonymous | 10MB / 2c | 5MB | 5MB / 2c | 24h | free | | Hobby | 1GB / 8c | 50MB | 100MB / 5c | none | $9 / mo | | Pro | 10GB / 20c | 512MB | 5GB / 20c | none | $49 / mo | -| Team | unlimited | unlimited | unlimited | none | coming soon | +| Team | unlimited | unlimited | unlimited | none | $199 / mo | "c" = simultaneous connections. The full table is at `/pricing`. @@ -17,11 +17,12 @@ reached via in-dashboard prompts when a Hobby user hits a quota wall. They are deliberately omitted from the public tier ladder to keep the customer-facing comparison simple. -**Team tier status:** the tier is defined in `plans.yaml` with the limits -above, but customer-initiated checkout for it is blocked at the API level -(`POST /api/v1/billing/checkout` returns `400 tier_unavailable` for -`plan=team`). Email support@instanode.dev for early access. Pro is the -ceiling for self-serve upgrades today. +**Team tier status:** Team is live and self-serve at $199/mo (the API no longer +returns `tier_unavailable` for `plan=team`). Note: self-serve checkout for ALL +paid tiers (Hobby/Pro/Team) currently depends on the Razorpay recurring-billing +rollout — until that operator step completes, `POST /api/v1/billing/checkout` +may return a `502`/`503`; contact support@instanode.dev for assisted onboarding +in the meantime. Limits are enforced at the Postgres user level (`CONNECTION LIMIT` on the role) and via per-bucket storage quotas. Exceeding a limit returns a 402 with diff --git a/docs/services.md b/docs/services.md index 4b7fbe8..217f8ef 100644 --- a/docs/services.md +++ b/docs/services.md @@ -54,10 +54,13 @@ curl -X POST https://api.instanode.dev/webhook/new \ -d '{"name":"github-webhook"}' ``` -Every response has the same shape: `{ ok, token, connection_url, internal_url, +Most responses share the shape `{ ok, token, connection_url, internal_url, tier, limits, note, upgrade_jwt }`. `internal_url` is the address to use when the caller itself runs inside our cluster (i.e. via /deploy/new) — -public hostnames don't hairpin reliably from inside. +public hostnames don't hairpin reliably from inside. Two endpoints differ: +`/webhook/new` returns `receive_url` (no `connection_url`/`internal_url`), and +`/storage/new` returns `endpoint`/`prefix`/`mode` (and, in `broker` mode, a +`presign_url` instead of S3 keys — see Storage isolation below). ### NATS queue credentials (2026-05-20+) @@ -82,12 +85,18 @@ NATS account credentials (MR-P0-5): Pass `(nats_jwt, nats_nkey)` to `nats.UserJWTAndSeed()` in the NATS Go client, or write `creds_file` to disk and pass the path to `nats.UserCredentials()`. -The tenant's JWT only permits pub/sub on the `subject_prefix.*` namespace — -publish to other tenants' subjects is denied at the server. - -Resources provisioned before the operator-mode cutover (2026-05-20) carry -`auth_mode: "legacy_open"` and have no `credentials` field; they keep working -on the unauthenticated path until they recycle. +When `auth_mode` is `"isolated"`, the tenant's JWT only permits pub/sub on the +`subject_prefix.*` namespace and cross-tenant publish is denied at the server. + +> **Current production reality (read this).** Per-tenant account-JWT isolation +> is wired end-to-end but is **not yet active in production** — prod currently +> issues `auth_mode: "legacy_open"` for new queues (operator NKey generation is +> pending). In `legacy_open` there is **no `credentials` block and no +> server-side cross-tenant enforcement**: connect with just the +> `connection_url`, and treat the queue as shared-namespace — scope your own +> subjects under `subject_prefix.*` at the application layer. The `isolated` +> shape above is what you'll receive once isolation is enabled. Resources +> provisioned before the 2026-05-20 cutover are also `legacy_open`. ### Storage isolation mode (2026-05-20+) @@ -96,10 +105,10 @@ isolation level the tenant landed on: | mode | Meaning | |---|---| -| `shared-master-key` | DO Spaces today. Every tenant holds the master key; isolation is by `prefix` convention. | -| `prefix-scoped` | Backend IAM enforces `s3:prefix` against `/*` (R2, S3, MinIO). | +| `broker` | **DO Spaces today — what every new tenant receives.** No long-lived credential is issued; the response omits `access_key_id`/`secret_access_key`. Use `POST /storage/:token/presign` for short-lived signed URLs (max 1h TTL). | +| `shared-master-key` | Legacy DO Spaces rows only (pre-broker). Every tenant held the master key; isolation was by `prefix` convention. New tenants do NOT land here. | +| `prefix-scoped` | Backend IAM enforces `s3:prefix` against `/*` (R2, S3, MinIO target). | | `prefix-scoped-temporary` | Same as prefix-scoped but credentials are STS — they expire. | -| `broker` | No long-lived credential is issued. Use `POST /storage/:token/presign` for short-lived signed URLs (max 1h TTL). | The mode is decided at boot time by the `OBJECT_STORE_BACKEND` env var and the backend's `Capabilities()`. Agents should branch on `mode` if they diff --git a/llms.txt b/llms.txt index 94ae9d8..cb67f13 100644 --- a/llms.txt +++ b/llms.txt @@ -14,7 +14,7 @@ On a prompt like that, the agent should: `POST /db/new` for Postgres, write the ## API endpoints -All accept `POST` against `https://api.instanode.dev`. No authentication header required for the anonymous tier, but every request body **must** include `name` (see below). Response shape: `{ok, token, connection_url, internal_url, tier, limits, note, upgrade_jwt, upgrade}` for db/cache/nosql/queue — where `upgrade` is the human-readable claim link (e.g. `https://api.instanode.dev/start?t=`) and `upgrade_jwt` is the raw JWT for programmatic use. `/webhook/new` returns `receive_url` (no internal_url). `/storage/new` adds `endpoint`, `access_key_id`, `secret_access_key`, `prefix` alongside `connection_url`. +All accept `POST` against `https://api.instanode.dev`. No authentication header required for the anonymous tier, but every request body **must** include `name` (see below). Response shape: `{ok, token, connection_url, internal_url, tier, limits, note, upgrade_jwt, upgrade}` for db/cache/nosql/queue — where `upgrade` is the human-readable claim link (e.g. `https://api.instanode.dev/start?t=`) and `upgrade_jwt` is the raw JWT for programmatic use. `/webhook/new` returns `receive_url` (no internal_url). `/storage/new` returns `endpoint`, `prefix`, and a `mode` field alongside `connection_url`; in the default `broker` mode (DO Spaces today, all tiers) it OMITS `access_key_id`/`secret_access_key` and instead returns `presign_url` + `agent_action:"use_presign_endpoint"` — call `POST /storage/:token/presign` for short-lived signed URLs (see the storage section below). ### Required `name` field (all provisioning endpoints) @@ -33,7 +33,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`, `access_key_id`, `secret_access_key`, `prefix`, and a `mode` field that names the isolation level the tenant landed on: `shared-master-key` (DO Spaces today — 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), or `broker` (no long-lived credential — call `POST /storage/:token/presign` for short-lived signed URLs, max 1h TTL). Configure an S3 client with `endpoint`, `access_key_id`, `secret_access_key`, bucket=`instant-shared`, and constrain object keys to start with `prefix`. Anonymous-tier objects are auto-deleted at 24h by a bucket lifecycle rule enforced at the storage layer. 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 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 /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. @@ -94,7 +94,7 @@ via dashboard prompts when a Hobby user hits a wall) and are deliberately omitte the public tier ladder to keep the customer-facing comparison simple. Both still surface on `/api/v1/capabilities` for agent introspection. -- **Anonymous**: free, 24h TTL, the limits above. +- **Anonymous**: free, 24h TTL, the limits above. Note: once an anonymous resource expires, the same network fingerprint must do a one-time email claim before provisioning a replacement — a repeat `POST //new` returns `402 {"error":"free_tier_recycle_requires_claim", "claim_url": "..."}` with an `agent_action`. Tell the user to claim at the `claim_url` (30 seconds, no card), then retry. - **Hobby**: $9/mo. 1 GB Postgres, 50 MB Redis, 100 MB Mongo, 512 MB storage, 1 app deploy. No TTL. > **Upgrading auto-promotes in-flight deployment TTLs.** When a team upgrades to any paid tier (Hobby / Hobby Plus / Pro / Growth / Team), the Razorpay subscription.charged webhook flips the team's `default_deployment_ttl_policy` from `auto_24h` to `permanent` (so every future `POST /deploy/new` defaults to no TTL) AND promotes every existing `auto_24h` non-terminal deploy to permanent (clearing `expires_at`). Per-deploy `ttl_policy='custom'` and `ttl_policy='permanent'` rows are never touched — only the `auto_24h` class is rolled forward. To restore the 24h-default behaviour after an upgrade, `PATCH /api/v1/team/settings {"default_deployment_ttl_policy":"auto_24h"}`. @@ -105,7 +105,7 @@ on `/api/v1/capabilities` for agent introspection. 0. **`name` is required on every provision** — `/db/new`, `/cache/new`, `/nosql/new`, `/queue/new`, `/storage/new`, `/webhook/new`, `/deploy/new`, `/stacks/new` all reject a request with no `name`. Send a JSON string field (`-d '{"name":"prod-db"}'`) — or a form field on `/deploy/new` and `/stacks/new` (`-F "name=my-app"`). Rules: 1–64 chars, `^[A-Za-z0-9][A-Za-z0-9 _-]*$`. Missing → `400 {"error":"name_required"}`; malformed → `400 {"error":"invalid_name"}`. 1. **One curl per service** — no batch endpoints, no /provision/all. Run them in parallel from bash with `&` or async in code. -2. **Parse the URL field directly** — every response has `.connection_url` except `/webhook/new` (`.receive_url`) and `/storage/new` (`.connection_url` plus `.endpoint`/`.access_key_id`/`.secret_access_key` for SDK use). Never construct URLs by hand. +2. **Parse the URL field directly** — every response has `.connection_url` except `/webhook/new` (`.receive_url`) and `/storage/new` (`.connection_url` plus `.endpoint`/`.prefix`/`.mode`; in `broker` mode — DO Spaces today — no `access_key_id`/`secret_access_key` is returned, use the `.presign_url` / `POST /storage/:token/presign` flow instead). Never construct URLs by hand. 3. **Save `upgrade_jwt`** if the user wants to claim the resource later. It's also required as a `Bearer` token for `POST /deploy/new`. 4. **No retry-on-201** — provisioning is synchronous. If the call returns 200/201, the resource is ready to accept connections. 5. **Internal vs public URL** — when the calling code itself runs *inside* a `/deploy/new` container, prefer `internal_url` (cluster-internal hostname). For everything else, use `connection_url` (public). diff --git a/pages/status.md b/pages/status.md index 09547de..4233f7c 100644 --- a/pages/status.md +++ b/pages/status.md @@ -34,5 +34,5 @@ For a chronological list of incidents, see the HTML page. Recent fixes and platf ## Machine-readable - Status JSON: `https://api.instanode.dev/api/v1/status` -- Per-service metrics (Prometheus format): `https://api.instanode.dev/metrics` +- Per-service metrics (Prometheus format): `https://api.instanode.dev/metrics` (requires a bearer token — not public; returns `401` without it) - OpenAPI spec: `https://api.instanode.dev/openapi.json`