From 4b5c664648f9dc34da58d2f8cdfe10acf6ac82ac Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 3 Jun 2026 01:05:17 +0530 Subject: [PATCH] fix(marketing): close gap-analysis findings (PricingPage SLA, status badge, llms storage/redeploy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified vs live + handlers (gap analysis 2026-06-03): - PricingPage SSO/SAML + 99.9% SLA cells showed a Team checkmark (delivered); no backend exists → marked "(coming soon)", all-tier dash (consistent with PricingGrid + llms.txt; #161 fixed those two surfaces but missed PricingPage). - Homepage footer "All systems normal" was a hardcoded string (would claim all-clear during an outage) → relabel to neutral "System status" link. - MCP tools card said "Seven tools registered" (understated — MCP registers ~19); now "Seven provisioning tools + stack/deployment management". Test updated to match (keeps the dropped-webhook anti-regression guard). - public/llms.txt (SERVED copy): /storage/new broker mode (no S3 keys → presign_url) + redeploy error code no_matching_deployment → no_existing_deployment_to_redeploy. Mirrors content/#18. Full vitest suite green (1107 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --- public/llms.txt | 8 ++++---- src/pages/MarketingPage.test.tsx | 12 ++++++++---- src/pages/MarketingPage.tsx | 9 +++++++-- src/pages/PricingPage.tsx | 6 ++++-- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/public/llms.txt b/public/llms.txt index a6548b5..094fc47 100644 --- a/public/llms.txt +++ b/public/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,14 +33,14 @@ 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), `prefix-scoped` (backend IAM enforces `s3:prefix` — R2/S3/MinIO target), `prefix-scoped-temporary` (same but STS-expiring). 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. - **`GET /healthz`** — Shallow liveness probe. Returns 200 with `{ok, commit_id, build_time, version}` if the binary is up and can ping its primary platform DB. Wired to the Kubernetes `livenessProbe`. Use `/readyz` for deep upstream checks. - **`GET /readyz`** — Deep readiness probe. Multi-component upstream reachability matrix (platform_db, customer_db, redis, provisioner_grpc, NATS, DO Spaces, Brevo, Razorpay, GeoIP). Per-check criticality: `platform_db` + `provisioner_grpc` are CRITICAL (failure → 503); everything else degrades to `200 + overall=degraded`. Each check runs in parallel behind a 10-15s cache to avoid self-DoS via the k8s `readinessProbe` cycle. Response envelope: `{ok, overall, commit_id, checks: {name: {status, latency_ms, last_checked, message?}}}`. Same shape served by api, worker, and provisioner. - **`POST /deploy/new`** — Container deploy. Multipart form: `tarball=@app.tar.gz` (required, gzipped tar containing Dockerfile + source, ≤50 MB) and `name=my-app` (**required** — same 1–64 char `^[A-Za-z0-9][A-Za-z0-9 _-]*$` rule), plus optional `port=8080`, `env=production` (scope), and `env_vars={"KEY":"VAL"}` (JSON string of env vars injected into the pod). Build runs in-cluster via kaniko (~30–90s); call returns `202` with `status=building`, then `status=healthy` once the URL on `*.deployment.instanode.dev` is live with a Let's Encrypt cert. **Requires a JWT** — `Authorization: Bearer `. - - **Pushing a new version of an existing app** (in-place update — same `app_id`, same URL, slot count unchanged): add `redeploy=true` as a multipart form field on the SAME `POST /deploy/new` call, with the SAME `name=` you used for the original deploy. The platform finds the existing deployment for that team + name and rebuilds it in place. The response includes `"redeployed": true` and reuses the original URL. If no matching deployment exists for that name, the call returns `404 {"error":"no_matching_deployment"}` — drop the flag and retry to create a fresh app. Without `redeploy=true`, every `POST /deploy/new` mints a NEW `app_id` and a NEW `*.deployment.instanode.dev` URL, even when `name` collides — so an agent shipping v2 of the same app MUST pass `redeploy=true` or the user ends up with two parallel deployments and two distinct URLs. + - **Pushing a new version of an existing app** (in-place update — same `app_id`, same URL, slot count unchanged): add `redeploy=true` as a multipart form field on the SAME `POST /deploy/new` call, with the SAME `name=` you used for the original deploy. The platform finds the existing deployment for that team + name and rebuilds it in place. The response includes `"redeployed": true` and reuses the original URL. If no matching deployment exists for that name, the call returns `404 {"error":"no_existing_deployment_to_redeploy"}` (and `409 not_ready` if the row has no provider id yet) — drop the flag and retry to create a fresh app. Without `redeploy=true`, every `POST /deploy/new` mints a NEW `app_id` and a NEW `*.deployment.instanode.dev` URL, even when `name` collides — so an agent shipping v2 of the same app MUST pass `redeploy=true` or the user ends up with two parallel deployments and two distinct URLs. - **`POST /stacks/new`** — Multi-service deploy. Multipart form: an `instant.yaml` manifest plus one tarball per service, and `name=my-stack` (**required** — same 1–64 char `^[A-Za-z0-9][A-Za-z0-9 _-]*$` rule). **Requires a JWT.** Returns `{ok, slug, stack_url, services: [{name, url, status}]}`. Anonymous stacks (no Bearer JWT) are accepted and expire after a 6h TTL (a stack is live compute — tighter than the 24h anon data-resource TTL; claim/upgrade to keep it). - **`GET /api/v1/stacks/{slug}`** — Inspect a stack by slug. Returns the manifest, current per-service status, exposed URLs, and the merged env-vars (redacted). Anonymous-tier stacks are readable by anyone holding the slug; authenticated stacks require the owning team's session JWT. - **`PATCH /stacks/{slug}/env`** — Merge env-vars into an existing stack. Body: `{"env_vars": {"KEY": "value"}}`. Setting a key to the empty string deletes it. Keys must match `[A-Z_][A-Z0-9_]*`. Total payload after merge capped at 64KiB. Persisted to `stacks.env_vars` JSONB; the next `POST /stacks/{slug}/redeploy` applies them. Anonymous stacks cannot be mutated post-creation. (Replaced a previously silent-no-op handler on 2026-05-20; do not assume any pre-2026-05-20 PATCH actually persisted.) @@ -104,7 +104,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/src/pages/MarketingPage.test.tsx b/src/pages/MarketingPage.test.tsx index d147ba6..1554616 100644 --- a/src/pages/MarketingPage.test.tsx +++ b/src/pages/MarketingPage.test.tsx @@ -97,11 +97,15 @@ describe('MarketingPage — claim consistency (T18 P1-4 / P1-6)', () => { const text = container.textContent ?? '' // Headline says "Seven services. One bundle." expect(text).toMatch(/Seven services\. One bundle\./) - // MCP tools card must NOT say "Six tools registered" (the dropped- - // webhook regression). It must say "Seven" and list webhook. - expect(text).not.toMatch(/Six tools registered/) - expect(text).toMatch(/Seven tools registered/) + // MCP tools card lists the seven provisioning tools (must still list + // webhook — anti-regression for the dropped-webhook bug) AND, per the + // 2026-06-03 gap fix, also surfaces the stack/deployment management tools + // (the MCP server registers more than seven; "Seven tools registered" was + // an understatement). + expect(text).not.toMatch(/Six provisioning tools/) + expect(text).toMatch(/Seven provisioning tools/) expect(text).toMatch(/webhook/) + expect(text).toMatch(/list_deployments/) }) it("Deploy service card claims a build window consistent with content/llms.txt (~60s, not '<10s')", () => { diff --git a/src/pages/MarketingPage.tsx b/src/pages/MarketingPage.tsx index 93e17fc..3a5dad2 100644 --- a/src/pages/MarketingPage.tsx +++ b/src/pages/MarketingPage.tsx @@ -691,7 +691,7 @@ export function MarketingPage() { SERVICES array). The earlier "Six tools" copy dropped webhook — a real MCP tool — and contradicted the same page. */} -

Seven tools registered: postgres, redis, mongo, queue, storage, webhook, deploy.

+

Seven provisioning tools: postgres, redis, mongo, queue, storage, webhook, deploy — plus stack & deployment management (create_stack, list_stacks, update_stack_env, list_deployments, get_deployment, redeploy, delete_deployment, …).

@@ -756,9 +756,14 @@ export function MarketingPage() {
© 2026 instanode, inc. + {/* Neutral link to the live status page. Previously hardcoded + "All systems normal" — a static string that would claim + all-clear even during an incident (gap analysis 2026-06-03). + The real per-component status lives on /status (StatusPage, + wired to GET /api/v1/status). */}
diff --git a/src/pages/PricingPage.tsx b/src/pages/PricingPage.tsx index 47c25ca..4182b01 100644 --- a/src/pages/PricingPage.tsx +++ b/src/pages/PricingPage.tsx @@ -162,8 +162,10 @@ const ROWS: Row[] = [ { label: 'Vault entries', values: [{ mark: 'dash' }, '20', '200', UNLIMITED] }, { label: 'Vault envs', values: [{ mark: 'dash' }, 'production only', 'multi-env', 'multi-env'] }, { label: 'Backups', values: [{ mark: 'dash' }, '7-day · no restore', '30-day · 1-click restore', '90-day · self-serve restore'] }, - { label: 'SSO / SAML', values: [{ mark: 'dash' }, { mark: 'dash' }, { mark: 'dash' }, { mark: 'check' }] }, - { label: '99.9% SLA', values: [{ mark: 'dash' }, { mark: 'dash' }, { mark: 'dash' }, { mark: 'check' }] } + // SSO/SAML + SLA have no backend yet — shown as not-yet-available everywhere + // (gap analysis 2026-06-03), consistent with PricingGrid + llms.txt "coming soon". + { label: 'SSO / SAML (coming soon)', values: [{ mark: 'dash' }, { mark: 'dash' }, { mark: 'dash' }, { mark: 'dash' }] }, + { label: '99.9% SLA (coming soon)', values: [{ mark: 'dash' }, { mark: 'dash' }, { mark: 'dash' }, { mark: 'dash' }] } ] const FAQ: { q: string; a: string }[] = [