Skip to content

Commit afbcf28

Browse files
fix(api): deploy_ttl anon walls emit claim_required, not upgrade_required (B7-P1-7)
POST /api/v1/deployments/:id/{make-permanent,ttl} returned 402 with `error: "upgrade_required"` on anonymous-tier callers. The agent_action sentence correctly said "claim the account (free)", but agents that branch on the `error` keyword route on `upgrade_required` to the paid pricing page (codeToAgentAction["upgrade_required"].UpgradeURL = https://instanode.dev/pricing). The actual remediation is a FREE claim, not a paid upgrade — wrong destination for the strict code-switching caller. Rule 17 coverage block: Symptom: JSON body `error: "upgrade_required"` on anon /make-permanent + /ttl Enumeration: rg -F '"upgrade_required"' internal/handlers/deploy_ttl.go Sites found: 2 (deploy_ttl.go:65, deploy_ttl.go:139) Sites touched: 2 (both flipped to "claim_required") Coverage test: TestDeployTTL_AnonymousArmsEmitClaimRequired (iterates a route table — a third arm that emits upgrade_required fails the matching row) + TestDeployTTL_NoUpgradeRequiredInSource (source-grep belt+braces) Live verified: pending — awaiting prod deploy + healthz commit_id gate Rule 22 surface check (contract change touches all surfaces in one PR): - internal/handlers/deploy_ttl.go (both anon walls) - internal/handlers/helpers.go (codeToAgentAction["claim_required"] registered with UpgradeURL: https://instanode.dev/claim) - internal/handlers/openapi.go (402 description on both routes) - openapi.snapshot.json (regenerated via `make openapi-snapshot`) - internal/handlers/agent_action_contract_test.go (expectedCodes registry — claim_required is now a guaranteed-present entry) Wire shape preserved: - Status: 402 (unchanged) - agent_action: still points at https://instanode.dev/claim with the same "Claim the account" copy (AgentActionDeployMakePermanentAnonymous) - upgrade_url: changes from https://api.instanode.dev/start (a 302 to /claim) to https://instanode.dev/claim directly — fewer hops for the calling agent Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 66f4d85 commit afbcf28

6 files changed

Lines changed: 223 additions & 10 deletions

File tree

internal/handlers/agent_action_contract_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,14 @@ func TestAgentActionContract_RegistryCoverage(t *testing.T) {
221221
// flagged it. If a future feature reintroduces a "tier is genuinely
222222
// unavailable" surface, re-add the code + its emitter in one PR.
223223
"upgrade_required", "rate_limit_exceeded",
224+
// B7-P1-7 (BugBash 2026-05-20): `claim_required` is the honest
225+
// 402 for anonymous-tier walls whose remediation is a FREE claim
226+
// (e.g. POST /api/v1/deployments/:id/{make-permanent,ttl}). A
227+
// drop from the registry without an in-PR migration to a different
228+
// code is a contract regression — agents that branch on the code
229+
// would either lose the agent_action or route the user to the
230+
// paid pricing page when the wall is free to clear.
231+
"claim_required",
224232
// Auth.
225233
"unauthorized", "auth_required", "invalid_token", "missing_token",
226234
"vault_requires_auth", "invitation_invalid", "already_accepted",

internal/handlers/deploy_ttl.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,20 @@ func (h *DeployHandler) MakePermanent(c *fiber.Ctx) error {
6161
}
6262

6363
if team.PlanTier == "anonymous" {
64+
// B7-P1-7 (BugBash 2026-05-20): the wall used to emit
65+
// `upgrade_required`, which an agent that branches on error code
66+
// alone routes to https://instanode.dev/pricing (paid checkout) —
67+
// but the actual remediation is a FREE claim, not a paid upgrade.
68+
// The agent_action sentence said "claim" correctly, but a strict
69+
// code-switching agent never reads it. `claim_required` is the
70+
// honest code for "free signup needed, not money." Wire shape
71+
// (402 status, message, agent_action) preserved; only the `error`
72+
// keyword and the upgrade_url destination change.
6473
return respondErrorWithAgentAction(c, fiber.StatusPaymentRequired,
65-
"upgrade_required",
66-
"Anonymous deploys cannot be made permanent — they always expire in 24h. Claim the account to keep deploys.",
74+
"claim_required",
75+
"Anonymous deploys cannot be made permanent — they always expire in 24h. Claim the account (free) to keep deploys.",
6776
AgentActionDeployMakePermanentAnonymous,
68-
"https://api.instanode.dev/start")
77+
"https://instanode.dev/claim")
6978
}
7079

7180
previousPolicy := d.TTLPolicy
@@ -135,11 +144,15 @@ func (h *DeployHandler) SetTTL(c *fiber.Ctx) error {
135144
}
136145

137146
if team.PlanTier == "anonymous" {
147+
// B7-P1-7 (see MakePermanent above): emit `claim_required` so an
148+
// agent branching on error code routes the user to the free claim
149+
// flow, not the paid pricing page. The `agent_action` sentence
150+
// already said "claim"; this aligns the machine-readable code.
138151
return respondErrorWithAgentAction(c, fiber.StatusPaymentRequired,
139-
"upgrade_required",
140-
"Anonymous deploys have a fixed 24h TTL — custom TTL requires a claimed account.",
152+
"claim_required",
153+
"Anonymous deploys have a fixed 24h TTL — custom TTL requires a claimed account (free).",
141154
AgentActionDeployMakePermanentAnonymous,
142-
"https://api.instanode.dev/start")
155+
"https://instanode.dev/claim")
143156
}
144157

145158
var body SetTTLRequest
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package handlers_test
2+
3+
// deploy_ttl_claim_required_test.go — B7-P1-7 (BugBash 2026-05-20)
4+
// regression gate for the anonymous-tier walls on the deploy-TTL keeper
5+
// endpoints.
6+
//
7+
// Bug class:
8+
// POST /api/v1/deployments/:id/make-permanent and POST /:id/ttl reject
9+
// anonymous-tier callers with 402. The wall's `error` code used to be
10+
// `upgrade_required`, which is the keyword for "paid plan needed" —
11+
// not the right semantics here, where the remediation is a FREE claim.
12+
// Agents that branch on the response `error` keyword (instead of reading
13+
// the prose agent_action) routed the user to the paid pricing page when
14+
// a 30-second free claim would have cleared the wall.
15+
//
16+
// Why a registry-iterating test (rule 18 — CLAUDE.md):
17+
// This is a two-site bug: MakePermanent (deploy_ttl.go:63-69) and SetTTL
18+
// (deploy_ttl.go:137-143) both emitted the wrong code. A hand-typed
19+
// single-route assertion would re-regress the moment a third TTL-keeper
20+
// route lands and re-uses the `upgrade_required` template. The table
21+
// below iterates EVERY anon-rejected deploy-TTL route and asserts the
22+
// contract identically; adding a new route without adding a row here
23+
// makes the failure mode loud, not silent.
24+
//
25+
// Surface coverage (rule 17):
26+
// Symptom: JSON body `error: "upgrade_required"` on anon /make-permanent + /ttl
27+
// Enumeration: rg -F '"upgrade_required"' internal/handlers/deploy_ttl.go
28+
// Sites found: 2 (L65, L139)
29+
// Sites touched: 2 (both arms flipped to "claim_required" in same PR)
30+
// Coverage test: this file — iterates a 2-route table; a third arm
31+
// that emits `upgrade_required` makes the matching row fail.
32+
// Live verified: pending — anonymous deploys cannot be made permanent
33+
// on a real prod hit; the unit test exercises both code
34+
// paths against a real test DB with an "anonymous"-tier
35+
// team. Live curl awaiting deploy.
36+
37+
import (
38+
"context"
39+
"encoding/json"
40+
"io"
41+
"net/http"
42+
"net/http/httptest"
43+
"os"
44+
"strings"
45+
"testing"
46+
47+
"github.com/google/uuid"
48+
"github.com/stretchr/testify/assert"
49+
"github.com/stretchr/testify/require"
50+
51+
"instant.dev/internal/models"
52+
"instant.dev/internal/testhelpers"
53+
)
54+
55+
// TestDeployTTL_AnonymousArmsEmitClaimRequired pins the contract for every
56+
// anonymous-tier wall on the deploy-TTL keeper endpoints. The table is the
57+
// registry — adding a new TTL route that rejects anon must add a row here.
58+
func TestDeployTTL_AnonymousArmsEmitClaimRequired(t *testing.T) {
59+
db, cleanDB := testhelpers.SetupTestDB(t)
60+
defer cleanDB()
61+
rdb, cleanRedis := testhelpers.SetupTestRedis(t)
62+
defer cleanRedis()
63+
64+
teamID := testhelpers.MustCreateTeamDB(t, db, "anonymous")
65+
sessionJWT := testhelpers.MustSignSessionJWT(t, "u-claim-1", teamID, "anon@example.com")
66+
67+
app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "deploy")
68+
defer cleanApp()
69+
70+
d, err := models.CreateDeployment(context.Background(), db, models.CreateDeploymentParams{
71+
TeamID: uuid.MustParse(teamID),
72+
AppID: "ttl-anon-" + uuid.NewString()[:6],
73+
Tier: "anonymous",
74+
})
75+
require.NoError(t, err)
76+
defer db.Exec(`DELETE FROM deployments WHERE id = $1`, d.ID)
77+
78+
// Registry of every anon-rejected deploy-TTL route. ADD A ROW HERE
79+
// when a new keeper endpoint lands and rejects anon — otherwise the
80+
// next emitter of `upgrade_required` will slip past this gate.
81+
type armCase struct {
82+
name string
83+
method string
84+
path string
85+
body string
86+
}
87+
arms := []armCase{
88+
{
89+
name: "make_permanent",
90+
method: http.MethodPost,
91+
path: "/api/v1/deployments/" + d.AppID + "/make-permanent",
92+
body: "",
93+
},
94+
{
95+
name: "set_ttl",
96+
method: http.MethodPost,
97+
path: "/api/v1/deployments/" + d.AppID + "/ttl",
98+
body: `{"hours":48}`,
99+
},
100+
}
101+
102+
for _, arm := range arms {
103+
t.Run(arm.name, func(t *testing.T) {
104+
var bodyReader io.Reader
105+
if arm.body != "" {
106+
bodyReader = strings.NewReader(arm.body)
107+
}
108+
req := httptest.NewRequest(arm.method, arm.path, bodyReader)
109+
if arm.body != "" {
110+
req.Header.Set("Content-Type", "application/json")
111+
}
112+
req.Header.Set("Authorization", "Bearer "+sessionJWT)
113+
114+
resp, err := app.Test(req, 5000)
115+
require.NoError(t, err)
116+
defer resp.Body.Close()
117+
body, _ := io.ReadAll(resp.Body)
118+
119+
assert.Equal(t, http.StatusPaymentRequired, resp.StatusCode,
120+
"%s: anon-tier must 402, got body=%s", arm.name, body)
121+
122+
var out struct {
123+
OK bool `json:"ok"`
124+
Error string `json:"error"`
125+
Message string `json:"message"`
126+
AgentAction string `json:"agent_action"`
127+
UpgradeURL string `json:"upgrade_url"`
128+
}
129+
require.NoError(t, json.Unmarshal(body, &out),
130+
"%s: response must be JSON envelope: %s", arm.name, body)
131+
132+
assert.False(t, out.OK, "%s: ok must be false", arm.name)
133+
134+
// THE bug — error code keyword. `upgrade_required` routes
135+
// agents to paid pricing; `claim_required` routes them to
136+
// the free claim flow.
137+
assert.Equal(t, "claim_required", out.Error,
138+
"%s: error keyword must be claim_required (agents branching on code route by this keyword); upgrade_required mis-routes to paid pricing", arm.name)
139+
140+
// upgrade_url is the machine-readable destination an agent
141+
// would surface as a CTA. For a FREE claim it must point at
142+
// /claim, not /pricing or /start (deprecated alias).
143+
assert.Equal(t, "https://instanode.dev/claim", out.UpgradeURL,
144+
"%s: upgrade_url must point at the free /claim flow, not /pricing", arm.name)
145+
146+
// agent_action sentence must still pass the U3 contract and
147+
// say "claim" (the action verb).
148+
assert.NotEmpty(t, out.AgentAction, "%s: agent_action must not be empty", arm.name)
149+
assert.Contains(t, strings.ToLower(out.AgentAction), "claim",
150+
"%s: agent_action must name the next action (claim)", arm.name)
151+
})
152+
}
153+
}
154+
155+
// TestDeployTTL_NoUpgradeRequiredInSource is the structural guard for the
156+
// same regression: if any future hand-edit re-introduces the
157+
// `"upgrade_required"` string into deploy_ttl.go's anon walls, this test
158+
// fails before the registry-iterating arm test even runs. Belt + braces.
159+
//
160+
// NOTE: this asserts on the deploy_ttl.go FILE (source-level grep), so it
161+
// catches the regression at compile-time-of-the-test rather than at the
162+
// HTTP boundary. The arm-iterating test above is the HTTP-boundary gate.
163+
func TestDeployTTL_NoUpgradeRequiredInSource(t *testing.T) {
164+
// Read the source file deterministically — golden-grep style. Tests
165+
// run with cwd == the package directory, so the relative path is the
166+
// source file alongside this test.
167+
const sourcePath = "deploy_ttl.go"
168+
rawBytes, err := os.ReadFile(sourcePath)
169+
require.NoError(t, err, "source file must be readable: %s", sourcePath)
170+
raw := string(rawBytes)
171+
172+
// Any string literal `"upgrade_required"` inside deploy_ttl.go is
173+
// a regression of B7-P1-7 — the anon walls there are required to
174+
// emit `claim_required` instead. Other handlers (db.go, vector.go,
175+
// nosql.go, ...) are still allowed to emit `upgrade_required`
176+
// because their walls really are paid-plan walls.
177+
assert.NotContains(t, raw, `"upgrade_required"`,
178+
"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")
179+
}

internal/handlers/helpers.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ var codeToAgentAction = map[string]errorCodeMeta{
129129
AgentAction: "Tell the user this feature requires the Pro plan or higher. Upgrade at https://instanode.dev/pricing — takes 30 seconds.",
130130
UpgradeURL: "https://instanode.dev/pricing",
131131
},
132+
// B7-P1-7 (BugBash 2026-05-20): `claim_required` is the honest 402
133+
// for "you're on anonymous tier and the action requires an account,
134+
// but the upgrade is a FREE claim, not a paid plan." Previously the
135+
// anonymous-tier walls on /api/v1/deployments/:id/{make-permanent,ttl}
136+
// emitted `upgrade_required`, which an agent branching on error code
137+
// alone routes to https://instanode.dev/pricing — the wrong URL.
138+
// Keep the agent_action sentence parallel to upgrade_required (Tell
139+
// the user … claim …), but point UpgradeURL at the free /claim flow
140+
// so the JSON `upgrade_url` field is correct for code-only routers.
141+
"claim_required": {
142+
AgentAction: "Tell the user this action requires a claimed account (free, no payment). Have them claim at https://instanode.dev/claim — takes 30 seconds.",
143+
UpgradeURL: "https://instanode.dev/claim",
144+
},
132145
// "tier_unavailable" was removed 2026-05-29 along with the Team-tier
133146
// checkout/change-plan guards (CEO BIZ-1). The only emitters of this
134147
// code lived in those two billing branches; with both gone, the

internal/handlers/openapi.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ const openAPISpec = `{
574574
"responses": {
575575
"200": { "description": "Deployment kept permanently", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeployResponse" } } } },
576576
"401": { "description": "Unauthorized" },
577-
"402": { "description": "upgrade_required — anonymous tier. agent_action points at https://api.instanode.dev/start." },
577+
"402": { "description": "claim_required — anonymous tier. The remediation is a FREE claim, not a paid upgrade; upgrade_url points at https://instanode.dev/claim." },
578578
"404": { "description": "Not found (or owned by another team)" }
579579
}
580580
}
@@ -594,7 +594,7 @@ const openAPISpec = `{
594594
"responses": {
595595
"200": { "description": "TTL updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeployResponse" } } } },
596596
"400": { "description": "invalid_hours — outside 1..8760" },
597-
"402": { "description": "upgrade_required — anonymous tier" },
597+
"402": { "description": "claim_required — anonymous tier (remediation is a free claim, not a paid upgrade)" },
598598
"404": { "description": "Not found" }
599599
}
600600
}

openapi.snapshot.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4116,7 +4116,7 @@
41164116
"description": "Unauthorized"
41174117
},
41184118
"402": {
4119-
"description": "upgrade_required — anonymous tier. agent_action points at https://api.instanode.dev/start."
4119+
"description": "claim_required — anonymous tier. The remediation is a FREE claim, not a paid upgrade; upgrade_url points at https://instanode.dev/claim."
41204120
},
41214121
"404": {
41224122
"description": "Not found (or owned by another team)"
@@ -4179,7 +4179,7 @@
41794179
"description": "invalid_hours — outside 1..8760"
41804180
},
41814181
"402": {
4182-
"description": "upgrade_required — anonymous tier"
4182+
"description": "claim_required — anonymous tier (remediation is a free claim, not a paid upgrade)"
41834183
},
41844184
"404": {
41854185
"description": "Not found"

0 commit comments

Comments
 (0)