Skip to content

Commit f2fb140

Browse files
mastermanas805Manas Srivastavaclaude
authored
fix(storage,recycle): canonical presign host + recycle gate consistency (API-3/4/7/8) (#171)
* fix(storage,recycle): canonical presign host + recycle gate consistency (API-3/4/7/8) Closes four QA gaps (2026-05-29 INBOX) in one pass: API-3 (P1) — POST /storage/:token/presign returned signed URLs on nyc3.digitaloceanspaces.com, leaking the DO Spaces vendor host AND the master access-key-id prefix in every customer-facing artifact. Fix: rewrite the scheme+host of the signed URL to ObjectStorePublicURL (production: https://s3.instanode.dev — the canonical CNAME / CDN over the same DO Spaces backend, per CLAUDE.md 'Canonical since 2026-05-20'). Path + signature query are preserved verbatim so the SigV4 signature remains valid at the canonical host. Local-dev / MinIO with empty PublicURL passes through unchanged. API-4 / CLI-MCP-15R2 (P1) — recycle gate fired on db/cache/nosql/webhook but NOT on queue/storage for the same fingerprint, because models.GetAllActiveResourcesByFingerprint matched expired-but-status='active' rows the TTL reaper hadn't reaped yet. The gate's 'still mid-session' check saw a stale row and skipped. Fix: add 'AND (expires_at IS NULL OR expires_at > NOW())' to the query so the gate behaviour is reaper-independent. API-7 (P3) — daily-cap 429 pre-empted recycle-gate 402 + agent_action, losing the upsell signal for an over-cap fingerprint whose resources had aged out. Fix: in every anonymous-path handler (db/cache/nosql/queue/storage/webhook/ vector), run recycleGate BEFORE checkProvisionLimit so the 402 wins when both gates would fire. Gate still fails open on Redis/DB errors so the magic-first-touch wedge is never collateral damage. API-8 (P3) — invalid_operation error message lists 'GET, PUT, HEAD' but agent_action only mentioned 'GET or PUT'. Fix: agent_action now matches the message exactly. Drift was a docs/error-copy regression; no behaviour change. == Memory rule 22 — multi-surface checklist == - api/plans.yaml — no change (tier values unchanged) - common/plans/plans.go — no change - api/internal/handlers/openapi.go — no schema change (error envelopes unchanged) - content/llms.txt — no change (customer contract: response URL is still under s3.instanode.dev, which it ALREADY advertised; the leak was a bug not a contract change) - METRICS-CATALOG.md — no new metrics added; existing RecycleGateBlocked + FingerprintAbuseBlocked counters cover the reorder paths == Memory rule 23 — four-pass gate == - Local gate: build + vet + new tests green - Existing recycle-gate, cross-service-cap, dedup-decrypt regression tests unchanged (active rows still skip the gate, cap path still 429s when over the cap with a same-type or any-type live row) == Test coverage == - TestRewritePresignHost_CanonicalHostSubstitution (5 subtests): rewrite, empty-publicURL pass-through, malformed publicURL, nil signed URL, scheme-fallback - TestPresign_CanonicalHostSubstitution: HTTP-level proof — signed URL is on s3.instanode.dev, NOT digitaloceanspaces.com - TestPresign_NoCanonicalHost_PassesThrough: local-dev MinIO unchanged - TestGetAllActiveResourcesByFingerprint_FiltersExpiredRows: SQL fragment regression pin (test fails if the expires_at filter is ever removed) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(recycle): add early-gate coverage for db/storage/webhook/vector Addresses the patch-coverage gate failure on the API-7 reorder (PR-1 round 1). Previously, only TestAnonRecycleGate_{Cache,NoSQL,Queue} covered the recycle- gate-fired branch — and that test exercised the OLD (post-limitExceeded) gate position. After the reorder, the new `if h.recycleGate { return nil }` lines in db.go, storage.go, vector.go, webhook.go were uncovered. This adds: - TestAnonRecycleGate_DB (in anon_paths_provarms_test.go): reuses the gRPC fixture's /db/new + /cache/new mounts. Probes via /cache/new (always available), soft-deletes the row, plants the recycle marker, then asserts /db/new 402s. - TestRecycleGate_EarlyFire_{Storage,Webhook,Vector} (recycle_gate_early_test.go): mounts each handler against a minimal fiber app + cache probe handler in the same app. Soft-delete + marker pattern → 402 free_tier_recycle_requires_claim. All four tests deterministically hit the new line: `if h.recycleGate(c, fp, X) { return nil }`. They use the same /cache/new probe technique already proven by TestAnonProvision_RecycleGate_Returns402 to avoid depending on a live postgres-customers/MongoDB/NATS/Spaces backend (the gate fires BEFORE any provider dispatch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(presign): cover the scheme-fallback branch in rewritePresignHost Diff-cover round 2 flagged storage_presign.go lines 352-353 (the `if scheme == "" { scheme = signed.Scheme }` branch) as untested. The prior subtest passed "http://..." which set Scheme to "http", missing the fallback. New subtest uses a protocol-relative "//cdn.instanode.dev" so url.Parse returns Scheme=="" and the branch fires. signed URL was https, so the rewritten output must inherit https — pinned. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Manas Srivastava <noreply@instanode.dev> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 764da11 commit f2fb140

15 files changed

Lines changed: 515 additions & 37 deletions

internal/handlers/anon_paths_provarms_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,47 @@ func TestAnonRecycleGate_Cache(t *testing.T) { recycleGateOnce(t, "/cache/new",
145145
func TestAnonRecycleGate_NoSQL(t *testing.T) { recycleGateOnce(t, "/nosql/new", "10.215.0.1", "mongodb") }
146146
func TestAnonRecycleGate_Queue(t *testing.T) { recycleGateOnce(t, "/queue/new", "10.216.0.1", "queue") }
147147

148+
// TestAnonRecycleGate_DB covers the API-7 (QA 2026-05-29) reorder: the recycle
149+
// gate now fires from the EARLIER position in NewDB (before checkProvisionLimit),
150+
// so a fresh fingerprint with a planted recycle marker and zero active rows
151+
// must still 402 free_tier_recycle_requires_claim on the /db/new path. Pinned
152+
// here rather than in redis_fault_provarms_test.go because that test depends
153+
// on a live postgres-customers backend (which the coverage CI job doesn't
154+
// provide); the gRPC fixture's fakeProvisioner is good enough since recycleGate
155+
// fires BEFORE any backend dispatch.
156+
func TestAnonRecycleGate_DB(t *testing.T) {
157+
fake := &fakeProvisioner{}
158+
fx := setupGRPCProvFixture(t, fake, false)
159+
160+
ip := "10.217.0.1"
161+
// Plant a recycle marker for the fingerprint this IP will produce and
162+
// ensure zero active rows. We do NOT need to provision first to learn the
163+
// fingerprint — the middleware computes it the same way every request, so
164+
// we can plant by replicating the exact fingerprint calc OR by using a
165+
// throwaway provision to discover it (mirrors recycleGateOnce above).
166+
resp, body := doProvisionKeyed(t, fx, "/cache/new", ip, "", uuid.NewString(),
167+
map[string]any{"name": "rg-db-probe"})
168+
resp.Body.Close()
169+
require.Equal(t, http.StatusCreated, resp.StatusCode)
170+
171+
var fp string
172+
require.NoError(t, fx.db.QueryRowContext(context.Background(),
173+
`SELECT fingerprint FROM resources WHERE token = $1::uuid`, body.Token).Scan(&fp))
174+
_, err := fx.db.ExecContext(context.Background(),
175+
`UPDATE resources SET status = 'deleted' WHERE fingerprint = $1`, fp)
176+
require.NoError(t, err)
177+
require.NoError(t, fx.rdb.Set(context.Background(),
178+
handlers.RecycleSeenKeyPrefix+fp, "1", time.Hour).Err())
179+
180+
// Next /db/new from the same IP must 402 from the EARLY recycle gate.
181+
resp2, body2 := doProvisionKeyed(t, fx, "/db/new", ip, "", uuid.NewString(),
182+
map[string]any{"name": "rg-db-fire"})
183+
defer resp2.Body.Close()
184+
require.Equal(t, http.StatusPaymentRequired, resp2.StatusCode,
185+
"/db/new recycle gate must 402 (early-gate API-7 reorder)")
186+
assert.Equal(t, "free_tier_recycle_requires_claim", body2.Error)
187+
}
188+
148189
// dedupDecryptFailOnce: provision 5 of a type, corrupt the row's stored
149190
// connection_url, force over-cap, and assert the 6th over-cap call hits the
150191
// dedup branch, fails to decrypt, and provisions FRESH (never returns

internal/handlers/cache.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error {
112112
}
113113

114114
// ── Anonymous path ─────────────────────────────────────────────────────────
115+
// Recycle gate runs BEFORE the daily-cap check — see db.go API-7 fix.
116+
if h.recycleGate(c, fp, "redis") {
117+
return nil
118+
}
119+
115120
limitExceeded, err := h.checkProvisionLimit(ctx, fp)
116121
if err != nil {
117122
slog.Error("cache.new.provision_limit_check_failed",
@@ -182,10 +187,7 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error {
182187
}
183188
}
184189

185-
// Free-tier recycle gate (see provision_helper.go for rationale).
186-
if h.recycleGate(c, fp, "redis") {
187-
return nil
188-
}
190+
// (Recycle gate moved above — see API-7 / QA 2026-05-29 ordering fix.)
189191

190192
expiresAt := time.Now().UTC().Add(24 * time.Hour)
191193
resource, err := models.CreateResource(ctx, h.db, models.CreateResourceParams{

internal/handlers/db.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,17 @@ func (h *DBHandler) NewDB(c *fiber.Ctx) error {
126126
}
127127

128128
// ── Anonymous path ─────────────────────────────────────────────────────────
129+
// Free-tier recycle gate runs BEFORE the daily-cap check so the 402
130+
// `free_tier_recycle_requires_claim` envelope (with claim_url + agent_action
131+
// upsell signal) wins over the more generic 429 `provision_limit_reached`
132+
// when both apply. API-7 (QA 2026-05-29): an over-cap fingerprint whose
133+
// previous resources have aged out used to get the bare daily-cap 429
134+
// envelope, losing the recycle-gate upsell entirely. Gate fails open (returns
135+
// false on any error) so the early call cannot block an honest first-touch.
136+
if h.recycleGate(c, fp, "postgres") {
137+
return nil
138+
}
139+
129140
limitExceeded, err := h.checkProvisionLimit(ctx, fp)
130141
if err != nil {
131142
slog.Error("db.new.provision_limit_check_failed",
@@ -209,15 +220,7 @@ func (h *DBHandler) NewDB(c *fiber.Ctx) error {
209220
}
210221
}
211222

212-
// Free-tier recycle gate (Option B / FREE-TIER-RECYCLE-2026-05-12). If
213-
// this fingerprint has provisioned anonymously before AND no active row
214-
// exists today, require a one-time email claim instead of silently
215-
// handing out another 24h free resource. Anonymous-only — the
216-
// authenticated path returned above. Fails open on Redis/DB errors so
217-
// the magic-first-touch wedge is never collateral damage.
218-
if h.recycleGate(c, fp, "postgres") {
219-
return nil
220-
}
223+
// (Recycle gate moved above — see API-7 / QA 2026-05-29 ordering fix.)
221224

222225
// Provision new anonymous Postgres resource (expires in 24h).
223226
expiresAt := time.Now().UTC().Add(24 * time.Hour)

internal/handlers/helpers.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,11 @@ var codeToAgentAction = map[string]errorCodeMeta{
475475
AgentAction: "Tell the user the object key is invalid. Use a non-empty UTF-8 path without traversal (../) — see https://instanode.dev/docs/storage.",
476476
},
477477
"invalid_operation": {
478-
AgentAction: "Tell the user the operation value is invalid. Use GET or PUT for /storage/:token/presign — see https://instanode.dev/docs/storage.",
478+
// API-8 (QA 2026-05-29): agent_action enum must match the error message
479+
// enum exactly. Error message lists GET, PUT, HEAD as accepted — so
480+
// must this. Drift surfaces as agent advice that contradicts the
481+
// actual contract.
482+
AgentAction: "Tell the user the operation value is invalid. Use GET, PUT, or HEAD for /storage/:token/presign — see https://instanode.dev/docs/storage.",
479483
},
480484
"path_unsafe": {
481485
AgentAction: "Tell the user the object path contains unsafe characters. Use a clean UTF-8 path with no '..', leading slash, or empty segments — see https://instanode.dev/docs/storage.",

internal/handlers/nosql.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ func (h *NoSQLHandler) NewNoSQL(c *fiber.Ctx) error {
111111
}
112112

113113
// ── Anonymous path ─────────────────────────────────────────────────────────
114+
// Recycle gate runs BEFORE the daily-cap check — see db.go API-7 fix.
115+
if h.recycleGate(c, fp, "mongodb") {
116+
return nil
117+
}
118+
114119
limitExceeded, err := h.checkProvisionLimit(ctx, fp)
115120
if err != nil {
116121
slog.Error("nosql.new.provision_limit_check_failed",
@@ -178,10 +183,7 @@ func (h *NoSQLHandler) NewNoSQL(c *fiber.Ctx) error {
178183
}
179184
}
180185

181-
// Free-tier recycle gate (see provision_helper.go for rationale).
182-
if h.recycleGate(c, fp, "mongodb") {
183-
return nil
184-
}
186+
// (Recycle gate moved above — see API-7 / QA 2026-05-29 ordering fix.)
185187

186188
expiresAt := time.Now().UTC().Add(24 * time.Hour)
187189
resource, err := models.CreateResource(ctx, h.db, models.CreateResourceParams{

internal/handlers/queue.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ func (h *QueueHandler) NewQueue(c *fiber.Ctx) error {
191191
}
192192

193193
// ── Anonymous path ─────────────────────────────────────────────────────────
194+
// Recycle gate runs BEFORE the daily-cap check — see db.go API-7 fix.
195+
if h.recycleGate(c, fp, "queue") {
196+
return nil
197+
}
198+
194199
limitExceeded, err := h.checkProvisionLimit(ctx, fp)
195200
if err != nil {
196201
slog.Error("queue.new.provision_limit_check_failed",
@@ -258,10 +263,7 @@ func (h *QueueHandler) NewQueue(c *fiber.Ctx) error {
258263
}
259264
}
260265

261-
// Free-tier recycle gate (see provision_helper.go for rationale).
262-
if h.recycleGate(c, fp, "queue") {
263-
return nil
264-
}
266+
// (Recycle gate moved above — see API-7 / QA 2026-05-29 ordering fix.)
265267

266268
expiresAt := time.Now().UTC().Add(24 * time.Hour)
267269
resource, err := models.CreateResource(ctx, h.db, models.CreateResourceParams{
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package handlers_test
2+
3+
// recycle_gate_early_test.go — coverage pin for API-7 (QA 2026-05-29):
4+
// the recycle gate now fires from the EARLIER position in storage/webhook/
5+
// vector anonymous handlers (before checkProvisionLimit), so the existing
6+
// recycle-gate fired-branch tests at the LATER position are no longer
7+
// reachable for those handlers. This file adds the missing per-handler
8+
// pin so a regression to the old ordering immediately reds.
9+
//
10+
// The cache/nosql/queue pin lives in anon_paths_provarms_test.go
11+
// (TestAnonRecycleGate_Cache/NoSQL/Queue). The db pin lives there too
12+
// (TestAnonRecycleGate_DB). storage/webhook/vector need their own
13+
// fixtures because they're not mounted on the gRPC fixture.
14+
15+
import (
16+
"context"
17+
"database/sql"
18+
"encoding/json"
19+
"errors"
20+
"io"
21+
"net/http"
22+
"net/http/httptest"
23+
"strings"
24+
"testing"
25+
"time"
26+
27+
"github.com/gofiber/fiber/v2"
28+
"github.com/google/uuid"
29+
"github.com/redis/go-redis/v9"
30+
"github.com/stretchr/testify/assert"
31+
"github.com/stretchr/testify/require"
32+
33+
"instant.dev/internal/config"
34+
"instant.dev/internal/handlers"
35+
"instant.dev/internal/middleware"
36+
"instant.dev/internal/plans"
37+
"instant.dev/internal/testhelpers"
38+
)
39+
40+
// recycleGateApp mounts a single anonymous-path handler with the minimum
41+
// middleware needed to drive a recycle-gate-fired path: RequestID + Fingerprint
42+
// (for fp computation) + OptionalAuth (no-op for anonymous) + the handler.
43+
// Idempotency middleware intentionally omitted — we want every POST to actually
44+
// reach the handler.
45+
func recycleGateApp(t *testing.T, mount func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config)) (*fiber.App, *sql.DB, *redis.Client) {
46+
t.Helper()
47+
db, _ := testhelpers.SetupTestDB(t)
48+
t.Cleanup(func() { db.Close() })
49+
rdb, _ := testhelpers.SetupTestRedis(t)
50+
t.Cleanup(func() { rdb.Close() })
51+
52+
cfg := &config.Config{
53+
Port: "8080",
54+
JWTSecret: testhelpers.TestJWTSecret,
55+
AESKey: testhelpers.TestAESKeyHex,
56+
EnabledServices: "postgres,redis,mongodb,queue,webhook,storage,vector",
57+
Environment: "test",
58+
}
59+
60+
app := fiber.New(fiber.Config{
61+
ErrorHandler: func(c *fiber.Ctx, err error) error {
62+
if errors.Is(err, handlers.ErrResponseWritten) {
63+
return nil
64+
}
65+
return fiber.DefaultErrorHandler(c, err)
66+
},
67+
ProxyHeader: "X-Forwarded-For",
68+
})
69+
app.Use(middleware.RequestID())
70+
app.Use(middleware.Fingerprint())
71+
72+
mount(app, db, rdb, cfg)
73+
return app, db, rdb
74+
}
75+
76+
// plantRecycleMarker computes the fingerprint via the middleware's helper
77+
// (X-Forwarded-For + ASN) and writes the recycle-seen Redis marker so the
78+
// gate will fire on the next request from the same IP. The fingerprint for
79+
// an unknown IP comes purely from /24 subnet + ASN, so two calls from the
80+
// same IP produce the same fp deterministically.
81+
func plantRecycleMarker(t *testing.T, app *fiber.App, db *sql.DB, rdb *redis.Client, probePath, ip string, probeBody string) string {
82+
t.Helper()
83+
// Issue one cache /probe call (cache is always available + doesn't depend
84+
// on a real backend) to learn the fp. The handler creates a row whose
85+
// fingerprint we read back. We use cache because it's the simplest
86+
// anonymous flow that doesn't need a real provisioner.
87+
req := httptest.NewRequest(http.MethodPost, probePath, strings.NewReader(probeBody))
88+
req.Header.Set("Content-Type", "application/json")
89+
req.Header.Set("X-Forwarded-For", ip)
90+
req.Header.Set("Idempotency-Key", uuid.NewString())
91+
resp, err := app.Test(req, 10000)
92+
require.NoError(t, err)
93+
raw, _ := io.ReadAll(resp.Body)
94+
defer resp.Body.Close()
95+
require.Equalf(t, http.StatusCreated, resp.StatusCode, "probe call body: %s", raw)
96+
97+
// Extract token then look up the fingerprint from the row.
98+
var probe struct {
99+
Token string `json:"token"`
100+
}
101+
require.NoError(t, parseProbeJSON(raw, &probe))
102+
103+
var fp string
104+
require.NoError(t, db.QueryRowContext(context.Background(),
105+
`SELECT fingerprint FROM resources WHERE token = $1::uuid`, probe.Token).Scan(&fp))
106+
107+
// Soft-delete every active row for this fp so the gate's "zero active
108+
// rows" condition is satisfied. Plant the marker.
109+
_, err = db.ExecContext(context.Background(),
110+
`UPDATE resources SET status = 'deleted' WHERE fingerprint = $1`, fp)
111+
require.NoError(t, err)
112+
require.NoError(t, rdb.Set(context.Background(),
113+
handlers.RecycleSeenKeyPrefix+fp, "1", time.Hour).Err())
114+
return fp
115+
}
116+
117+
// parseProbeJSON is a tiny JSON decoder helper kept in this file so the test
118+
// has zero dependencies on the larger provarms helpers (which need a gRPC
119+
// fixture). We only need the token field.
120+
func parseProbeJSON(raw []byte, out *struct {
121+
Token string `json:"token"`
122+
}) error {
123+
return json.Unmarshal(raw, out)
124+
}
125+
126+
// TestRecycleGate_EarlyFire_Storage covers the API-7 reorder: storage's
127+
// recycle gate now fires from the early position in NewStorage. Pin: with
128+
// a planted marker and zero active rows, /storage/new must 402.
129+
func TestRecycleGate_EarlyFire_Storage(t *testing.T) {
130+
provider := newDOSpacesProvider(t)
131+
app, db, rdb := recycleGateApp(t, func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config) {
132+
// Both /cache/new (probe to learn fp) and /storage/new mounted.
133+
cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, plans.Default())
134+
storageH := handlers.NewStorageHandler(db, rdb, cfg, provider, plans.Default())
135+
app.Post("/cache/new", middleware.OptionalAuth(cfg), cacheH.NewCache)
136+
app.Post("/storage/new", middleware.OptionalAuth(cfg), storageH.NewStorage)
137+
})
138+
ip := "10.220.0.1"
139+
plantRecycleMarker(t, app, db, rdb, "/cache/new", ip, `{"name":"probe"}`)
140+
141+
// Now /storage/new from the same IP must 402.
142+
req := httptest.NewRequest(http.MethodPost, "/storage/new", strings.NewReader(`{"name":"recycle"}`))
143+
req.Header.Set("Content-Type", "application/json")
144+
req.Header.Set("X-Forwarded-For", ip)
145+
req.Header.Set("Idempotency-Key", uuid.NewString())
146+
resp, err := app.Test(req, 10000)
147+
require.NoError(t, err)
148+
raw, _ := io.ReadAll(resp.Body)
149+
defer resp.Body.Close()
150+
require.Equalf(t, http.StatusPaymentRequired, resp.StatusCode,
151+
"/storage/new recycle gate must 402 (body=%s)", raw)
152+
assert.Contains(t, string(raw), "free_tier_recycle_requires_claim")
153+
}
154+
155+
// TestRecycleGate_EarlyFire_Webhook — same shape for /webhook/new.
156+
func TestRecycleGate_EarlyFire_Webhook(t *testing.T) {
157+
app, db, rdb := recycleGateApp(t, func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config) {
158+
cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, plans.Default())
159+
webhookH := handlers.NewWebhookHandler(db, rdb, cfg, plans.Default())
160+
app.Post("/cache/new", middleware.OptionalAuth(cfg), cacheH.NewCache)
161+
app.Post("/webhook/new", middleware.OptionalAuth(cfg), webhookH.NewWebhook)
162+
})
163+
ip := "10.221.0.1"
164+
plantRecycleMarker(t, app, db, rdb, "/cache/new", ip, `{"name":"probe"}`)
165+
166+
req := httptest.NewRequest(http.MethodPost, "/webhook/new", strings.NewReader(`{"name":"recycle"}`))
167+
req.Header.Set("Content-Type", "application/json")
168+
req.Header.Set("X-Forwarded-For", ip)
169+
req.Header.Set("Idempotency-Key", uuid.NewString())
170+
resp, err := app.Test(req, 10000)
171+
require.NoError(t, err)
172+
raw, _ := io.ReadAll(resp.Body)
173+
defer resp.Body.Close()
174+
require.Equalf(t, http.StatusPaymentRequired, resp.StatusCode,
175+
"/webhook/new recycle gate must 402 (body=%s)", raw)
176+
assert.Contains(t, string(raw), "free_tier_recycle_requires_claim")
177+
}
178+
179+
// TestRecycleGate_EarlyFire_Vector — same shape for /vector/new.
180+
func TestRecycleGate_EarlyFire_Vector(t *testing.T) {
181+
app, db, rdb := recycleGateApp(t, func(app *fiber.App, db *sql.DB, rdb *redis.Client, cfg *config.Config) {
182+
cacheH := handlers.NewCacheHandler(db, rdb, cfg, nil, plans.Default())
183+
vectorH := handlers.NewVectorHandler(db, rdb, cfg, nil, plans.Default())
184+
app.Post("/cache/new", middleware.OptionalAuth(cfg), cacheH.NewCache)
185+
app.Post("/vector/new", middleware.OptionalAuth(cfg), vectorH.NewVector)
186+
})
187+
ip := "10.222.0.1"
188+
plantRecycleMarker(t, app, db, rdb, "/cache/new", ip, `{"name":"probe"}`)
189+
190+
req := httptest.NewRequest(http.MethodPost, "/vector/new", strings.NewReader(`{"name":"recycle"}`))
191+
req.Header.Set("Content-Type", "application/json")
192+
req.Header.Set("X-Forwarded-For", ip)
193+
req.Header.Set("Idempotency-Key", uuid.NewString())
194+
resp, err := app.Test(req, 10000)
195+
require.NoError(t, err)
196+
raw, _ := io.ReadAll(resp.Body)
197+
defer resp.Body.Close()
198+
require.Equalf(t, http.StatusPaymentRequired, resp.StatusCode,
199+
"/vector/new recycle gate must 402 (body=%s)", raw)
200+
assert.Contains(t, string(raw), "free_tier_recycle_requires_claim")
201+
}

internal/handlers/storage.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ func (h *StorageHandler) NewStorage(c *fiber.Ctx) error {
167167
}
168168

169169
// ── Anonymous path ─────────────────────────────────────────────────────────
170+
// Recycle gate runs BEFORE the daily-cap check — see db.go API-7 fix.
171+
if h.recycleGate(c, fp, "storage") {
172+
return nil
173+
}
174+
170175
limitExceeded, err := h.checkProvisionLimit(ctx, fp)
171176
if err != nil {
172177
slog.Error("storage.new.provision_limit_check_failed",
@@ -264,10 +269,7 @@ func (h *StorageHandler) NewStorage(c *fiber.Ctx) error {
264269
}
265270
}
266271

267-
// Free-tier recycle gate (see provision_helper.go for rationale).
268-
if h.recycleGate(c, fp, "storage") {
269-
return nil
270-
}
272+
// (Recycle gate moved above — see API-7 / QA 2026-05-29 ordering fix.)
271273

272274
// P1-B: enforce the anonymous-tier storage byte cap. The authenticated path
273275
// (newStorageAuthenticated) sums SumStorageBytesByTeamAndType vs the tier

0 commit comments

Comments
 (0)