diff --git a/internal/handlers/agent_action_contract_test.go b/internal/handlers/agent_action_contract_test.go index 068edf51..16689c81 100644 --- a/internal/handlers/agent_action_contract_test.go +++ b/internal/handlers/agent_action_contract_test.go @@ -221,6 +221,14 @@ func TestAgentActionContract_RegistryCoverage(t *testing.T) { // flagged it. If a future feature reintroduces a "tier is genuinely // unavailable" surface, re-add the code + its emitter in one PR. "upgrade_required", "rate_limit_exceeded", + // B7-P1-7 (BugBash 2026-05-20): `claim_required` is the honest + // 402 for anonymous-tier walls whose remediation is a FREE claim + // (e.g. POST /api/v1/deployments/:id/{make-permanent,ttl}). A + // drop from the registry without an in-PR migration to a different + // code is a contract regression — agents that branch on the code + // would either lose the agent_action or route the user to the + // paid pricing page when the wall is free to clear. + "claim_required", // Auth. "unauthorized", "auth_required", "invalid_token", "missing_token", "vault_requires_auth", "invitation_invalid", "already_accepted", diff --git a/internal/handlers/deploy_ttl.go b/internal/handlers/deploy_ttl.go index 79c5dd7d..691ba59b 100644 --- a/internal/handlers/deploy_ttl.go +++ b/internal/handlers/deploy_ttl.go @@ -61,11 +61,20 @@ func (h *DeployHandler) MakePermanent(c *fiber.Ctx) error { } if team.PlanTier == "anonymous" { + // B7-P1-7 (BugBash 2026-05-20): the wall used to emit + // `upgrade_required`, which an agent that branches on error code + // alone routes to https://instanode.dev/pricing (paid checkout) — + // but the actual remediation is a FREE claim, not a paid upgrade. + // The agent_action sentence said "claim" correctly, but a strict + // code-switching agent never reads it. `claim_required` is the + // honest code for "free signup needed, not money." Wire shape + // (402 status, message, agent_action) preserved; only the `error` + // keyword and the upgrade_url destination change. return respondErrorWithAgentAction(c, fiber.StatusPaymentRequired, - "upgrade_required", - "Anonymous deploys cannot be made permanent — they always expire in 24h. Claim the account to keep deploys.", + "claim_required", + "Anonymous deploys cannot be made permanent — they always expire in 24h. Claim the account (free) to keep deploys.", AgentActionDeployMakePermanentAnonymous, - "https://api.instanode.dev/start") + "https://instanode.dev/claim") } previousPolicy := d.TTLPolicy @@ -135,11 +144,15 @@ func (h *DeployHandler) SetTTL(c *fiber.Ctx) error { } if team.PlanTier == "anonymous" { + // B7-P1-7 (see MakePermanent above): emit `claim_required` so an + // agent branching on error code routes the user to the free claim + // flow, not the paid pricing page. The `agent_action` sentence + // already said "claim"; this aligns the machine-readable code. return respondErrorWithAgentAction(c, fiber.StatusPaymentRequired, - "upgrade_required", - "Anonymous deploys have a fixed 24h TTL — custom TTL requires a claimed account.", + "claim_required", + "Anonymous deploys have a fixed 24h TTL — custom TTL requires a claimed account (free).", AgentActionDeployMakePermanentAnonymous, - "https://api.instanode.dev/start") + "https://instanode.dev/claim") } var body SetTTLRequest diff --git a/internal/handlers/deploy_ttl_claim_required_test.go b/internal/handlers/deploy_ttl_claim_required_test.go new file mode 100644 index 00000000..0ed6e0d3 --- /dev/null +++ b/internal/handlers/deploy_ttl_claim_required_test.go @@ -0,0 +1,179 @@ +package handlers_test + +// deploy_ttl_claim_required_test.go — B7-P1-7 (BugBash 2026-05-20) +// regression gate for the anonymous-tier walls on the deploy-TTL keeper +// endpoints. +// +// Bug class: +// POST /api/v1/deployments/:id/make-permanent and POST /:id/ttl reject +// anonymous-tier callers with 402. The wall's `error` code used to be +// `upgrade_required`, which is the keyword for "paid plan needed" — +// not the right semantics here, where the remediation is a FREE claim. +// Agents that branch on the response `error` keyword (instead of reading +// the prose agent_action) routed the user to the paid pricing page when +// a 30-second free claim would have cleared the wall. +// +// Why a registry-iterating test (rule 18 — CLAUDE.md): +// This is a two-site bug: MakePermanent (deploy_ttl.go:63-69) and SetTTL +// (deploy_ttl.go:137-143) both emitted the wrong code. A hand-typed +// single-route assertion would re-regress the moment a third TTL-keeper +// route lands and re-uses the `upgrade_required` template. The table +// below iterates EVERY anon-rejected deploy-TTL route and asserts the +// contract identically; adding a new route without adding a row here +// makes the failure mode loud, not silent. +// +// Surface coverage (rule 17): +// Symptom: JSON body `error: "upgrade_required"` on anon /make-permanent + /ttl +// Enumeration: rg -F '"upgrade_required"' internal/handlers/deploy_ttl.go +// Sites found: 2 (L65, L139) +// Sites touched: 2 (both arms flipped to "claim_required" in same PR) +// Coverage test: this file — iterates a 2-route table; a third arm +// that emits `upgrade_required` makes the matching row fail. +// Live verified: pending — anonymous deploys cannot be made permanent +// on a real prod hit; the unit test exercises both code +// paths against a real test DB with an "anonymous"-tier +// team. Live curl awaiting deploy. + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// TestDeployTTL_AnonymousArmsEmitClaimRequired pins the contract for every +// anonymous-tier wall on the deploy-TTL keeper endpoints. The table is the +// registry — adding a new TTL route that rejects anon must add a row here. +func TestDeployTTL_AnonymousArmsEmitClaimRequired(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "anonymous") + sessionJWT := testhelpers.MustSignSessionJWT(t, "u-claim-1", teamID, "anon@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "deploy") + defer cleanApp() + + d, err := models.CreateDeployment(context.Background(), db, models.CreateDeploymentParams{ + TeamID: uuid.MustParse(teamID), + AppID: "ttl-anon-" + uuid.NewString()[:6], + Tier: "anonymous", + }) + require.NoError(t, err) + defer db.Exec(`DELETE FROM deployments WHERE id = $1`, d.ID) + + // Registry of every anon-rejected deploy-TTL route. ADD A ROW HERE + // when a new keeper endpoint lands and rejects anon — otherwise the + // next emitter of `upgrade_required` will slip past this gate. + type armCase struct { + name string + method string + path string + body string + } + arms := []armCase{ + { + name: "make_permanent", + method: http.MethodPost, + path: "/api/v1/deployments/" + d.AppID + "/make-permanent", + body: "", + }, + { + name: "set_ttl", + method: http.MethodPost, + path: "/api/v1/deployments/" + d.AppID + "/ttl", + body: `{"hours":48}`, + }, + } + + for _, arm := range arms { + t.Run(arm.name, func(t *testing.T) { + var bodyReader io.Reader + if arm.body != "" { + bodyReader = strings.NewReader(arm.body) + } + req := httptest.NewRequest(arm.method, arm.path, bodyReader) + if arm.body != "" { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+sessionJWT) + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, http.StatusPaymentRequired, resp.StatusCode, + "%s: anon-tier must 402, got body=%s", arm.name, body) + + var out struct { + OK bool `json:"ok"` + Error string `json:"error"` + Message string `json:"message"` + AgentAction string `json:"agent_action"` + UpgradeURL string `json:"upgrade_url"` + } + require.NoError(t, json.Unmarshal(body, &out), + "%s: response must be JSON envelope: %s", arm.name, body) + + assert.False(t, out.OK, "%s: ok must be false", arm.name) + + // THE bug — error code keyword. `upgrade_required` routes + // agents to paid pricing; `claim_required` routes them to + // the free claim flow. + assert.Equal(t, "claim_required", out.Error, + "%s: error keyword must be claim_required (agents branching on code route by this keyword); upgrade_required mis-routes to paid pricing", arm.name) + + // upgrade_url is the machine-readable destination an agent + // would surface as a CTA. For a FREE claim it must point at + // /claim, not /pricing or /start (deprecated alias). + assert.Equal(t, "https://instanode.dev/claim", out.UpgradeURL, + "%s: upgrade_url must point at the free /claim flow, not /pricing", arm.name) + + // agent_action sentence must still pass the U3 contract and + // say "claim" (the action verb). + assert.NotEmpty(t, out.AgentAction, "%s: agent_action must not be empty", arm.name) + assert.Contains(t, strings.ToLower(out.AgentAction), "claim", + "%s: agent_action must name the next action (claim)", arm.name) + }) + } +} + +// TestDeployTTL_NoUpgradeRequiredInSource is the structural guard for the +// same regression: if any future hand-edit re-introduces the +// `"upgrade_required"` string into deploy_ttl.go's anon walls, this test +// fails before the registry-iterating arm test even runs. Belt + braces. +// +// NOTE: this asserts on the deploy_ttl.go FILE (source-level grep), so it +// catches the regression at compile-time-of-the-test rather than at the +// HTTP boundary. The arm-iterating test above is the HTTP-boundary gate. +func TestDeployTTL_NoUpgradeRequiredInSource(t *testing.T) { + // Read the source file deterministically — golden-grep style. Tests + // run with cwd == the package directory, so the relative path is the + // source file alongside this test. + const sourcePath = "deploy_ttl.go" + rawBytes, err := os.ReadFile(sourcePath) + require.NoError(t, err, "source file must be readable: %s", sourcePath) + raw := string(rawBytes) + + // Any string literal `"upgrade_required"` inside deploy_ttl.go is + // a regression of B7-P1-7 — the anon walls there are required to + // emit `claim_required` instead. Other handlers (db.go, vector.go, + // nosql.go, ...) are still allowed to emit `upgrade_required` + // because their walls really are paid-plan walls. + assert.NotContains(t, raw, `"upgrade_required"`, + "B7-P1-7 regression: deploy_ttl.go must not emit the `upgrade_required` keyword — anon-tier walls here are FREE-claim walls and must emit `claim_required` so code-switching agents route to /claim instead of /pricing") +} diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 67d16ee2..e9a4a254 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -129,6 +129,19 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user this feature requires the Pro plan or higher. Upgrade at https://instanode.dev/pricing — takes 30 seconds.", UpgradeURL: "https://instanode.dev/pricing", }, + // B7-P1-7 (BugBash 2026-05-20): `claim_required` is the honest 402 + // for "you're on anonymous tier and the action requires an account, + // but the upgrade is a FREE claim, not a paid plan." Previously the + // anonymous-tier walls on /api/v1/deployments/:id/{make-permanent,ttl} + // emitted `upgrade_required`, which an agent branching on error code + // alone routes to https://instanode.dev/pricing — the wrong URL. + // Keep the agent_action sentence parallel to upgrade_required (Tell + // the user … claim …), but point UpgradeURL at the free /claim flow + // so the JSON `upgrade_url` field is correct for code-only routers. + "claim_required": { + AgentAction: "Tell the user this action requires a claimed account (free, no payment). Have them claim at https://instanode.dev/claim — takes 30 seconds.", + UpgradeURL: "https://instanode.dev/claim", + }, // "tier_unavailable" was removed 2026-05-29 along with the Team-tier // checkout/change-plan guards (CEO BIZ-1). The only emitters of this // code lived in those two billing branches; with both gone, the diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index 7d731110..ba18825f 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -574,7 +574,7 @@ const openAPISpec = `{ "responses": { "200": { "description": "Deployment kept permanently", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeployResponse" } } } }, "401": { "description": "Unauthorized" }, - "402": { "description": "upgrade_required — anonymous tier. agent_action points at https://api.instanode.dev/start." }, + "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)" } } } @@ -594,7 +594,7 @@ const openAPISpec = `{ "responses": { "200": { "description": "TTL updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeployResponse" } } } }, "400": { "description": "invalid_hours — outside 1..8760" }, - "402": { "description": "upgrade_required — anonymous tier" }, + "402": { "description": "claim_required — anonymous tier (remediation is a free claim, not a paid upgrade)" }, "404": { "description": "Not found" } } } diff --git a/openapi.snapshot.json b/openapi.snapshot.json index 20aa4b8d..f76b8cfb 100644 --- a/openapi.snapshot.json +++ b/openapi.snapshot.json @@ -4116,7 +4116,7 @@ "description": "Unauthorized" }, "402": { - "description": "upgrade_required — anonymous tier. agent_action points at https://api.instanode.dev/start." + "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)" @@ -4179,7 +4179,7 @@ "description": "invalid_hours — outside 1..8760" }, "402": { - "description": "upgrade_required — anonymous tier" + "description": "claim_required — anonymous tier (remediation is a free claim, not a paid upgrade)" }, "404": { "description": "Not found"