Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/handlers/agent_action_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 19 additions & 6 deletions internal/handlers/deploy_ttl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions internal/handlers/deploy_ttl_claim_required_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
13 changes: 13 additions & 0 deletions internal/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/handlers/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)" }
}
}
Expand All @@ -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" }
}
}
Expand Down
4 changes: 2 additions & 2 deletions openapi.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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"
Expand Down
Loading