From ce625ada34191b084ed97c215feda39cf98aa7cf Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sun, 31 May 2026 17:04:40 +0530 Subject: [PATCH] feat(stacks): anonymous stacks expire after 6h, not 24h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stack is live compute (build pod + running services), so the anonymous window should be tighter than the 24h anon data-resource TTL. The zero-signup wedge stays — agents ship a working app URL anonymously — but the free compute window is bounded; claim/upgrade keeps an app past 6h. Introduce anonymousStackTTL (6h) + anonymousStackTTLLabel ("6h") as named constants so the expires_at write, the response expires_in field, and the upgrade-nudge note copy stay in lock-step (rule 16: one token, all sites). TestStackNew_Anonymous_Returns202 now asserts expires_in="6h", the 6h note copy, and expires_at ≈ now()+6h on the persisted row. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/handlers/stack.go | 27 +++++++++++++++------ internal/handlers/stack_deployasync_test.go | 2 +- internal/handlers/stack_test.go | 17 ++++++++++--- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/internal/handlers/stack.go b/internal/handlers/stack.go index 9c486142..d2bc2c65 100644 --- a/internal/handlers/stack.go +++ b/internal/handlers/stack.go @@ -4,7 +4,7 @@ package handlers // // POST /stacks/new, GET /stacks/:slug, GET /stacks/:slug/logs/:svc, and // DELETE /stacks/:slug use OptionalAuth — anonymous users can deploy stacks -// exactly as they provision databases (same tier system, 24h TTL, fingerprint dedup). +// exactly as they provision databases (same tier system, 6h TTL, fingerprint dedup). // // PATCH /stacks/:slug/env and POST /stacks/:slug/redeploy require auth (mutations // on owned stacks only). GET /api/v1/stacks requires auth (team-scoped listing). @@ -59,6 +59,18 @@ import ( // legitimate request. const stackStatusDeleting = "deleting" +// anonymousStackTTL is the lifetime of an anonymous (no-team) stack. A stack +// is live compute (build pod + running services), so the anonymous window is +// tighter than the 24h anon-resource data TTL — claiming/upgrading is the path +// to keep a deployed app past this window. Kept as a named constant so the +// expires_at write, the response "expires_in" field, and the user-facing note +// copy stay in lock-step (rule 16: one token, all call sites). +const anonymousStackTTL = 6 * time.Hour + +// anonymousStackTTLLabel is the human string for anonymousStackTTL, surfaced in +// the /stacks/new response (expires_in) and the upgrade-nudge note. +const anonymousStackTTLLabel = "6h" + // openMultipartFile opens an uploaded multipart file. It is a package-level // indirection (defaulting to the real (*multipart.FileHeader).Open) so coverage // tests can force the open-but-fail-read and open-error arms of the stack/deploy @@ -423,8 +435,9 @@ func (h *StackHandler) runStackRedeploy( // - {serviceName}: gzipped tarball for each service declared in the manifest // - name: optional human label for the stack // -// Anonymous deploys are supported (no auth required). Anonymous stacks expire in 24h, -// matching the same model used by /db/new, /cache/new, etc. +// Anonymous deploys are supported (no auth required). Anonymous stacks expire in 6h +// (anonymousStackTTL) — a stack is live compute, so the window is tighter than the +// 24h anon-resource data TTL; claim/upgrade to keep a deployed app past it. func (h *StackHandler) New(c *fiber.Ctx) error { // OptionalAuth: team is nil for anonymous deployments (router uses OptionalAuth). team, authErr := h.optionalStackTeam(c) @@ -660,7 +673,7 @@ func (h *StackHandler) New(c *fiber.Ctx) error { stackEnv = validated } - // Anonymous stacks: nil TeamID + 24h TTL + fingerprint (same model as /db/new). + // Anonymous stacks: nil TeamID + 6h TTL + fingerprint (same model as /db/new). // Authenticated stacks: real TeamID + plan tier from the team record. var ( stackTeamID *uuid.UUID @@ -669,7 +682,7 @@ func (h *StackHandler) New(c *fiber.Ctx) error { stackTier = "anonymous" ) if anon { - exp := time.Now().Add(24 * time.Hour) + exp := time.Now().Add(anonymousStackTTL) stackExpiresAt = &exp stackFingerprint = middleware.GetFingerprint(c) } else { @@ -843,7 +856,7 @@ func (h *StackHandler) New(c *fiber.Ctx) error { // Step 9: Return 202. noteMsg := "Stack is building. Poll GET /stacks/" + slug + " for status." if anon { - noteMsg += " Anonymous stacks expire in 24h. Upgrade at " + urls.StartURLPrefix + "" + noteMsg += " Anonymous stacks expire in " + anonymousStackTTLLabel + ". Upgrade at " + urls.StartURLPrefix + "" } if len(warnings) > 0 { noteMsg = fmt.Sprintf("%d warning(s) from manifest parsing. %s", len(warnings), noteMsg) @@ -863,7 +876,7 @@ func (h *StackHandler) New(c *fiber.Ctx) error { "tier": stackTier, "expires_in": func() string { if anon { - return "24h" + return anonymousStackTTLLabel } return "" }(), diff --git a/internal/handlers/stack_deployasync_test.go b/internal/handlers/stack_deployasync_test.go index 2c772b50..3d1d14e0 100644 --- a/internal/handlers/stack_deployasync_test.go +++ b/internal/handlers/stack_deployasync_test.go @@ -842,7 +842,7 @@ func TestStackList_WithRows(t *testing.T) { // ── Anonymous-path New coverage (needs a real Redis for the rate-limit) ────── // TestStackNew_Anonymous_Succeeds drives the full anonymous /stacks/new path: -// fingerprint rate-limit (fail-open / not-exceeded), anon TeamID=nil + 24h TTL, +// fingerprint rate-limit (fail-open / not-exceeded), anon TeamID=nil + 6h TTL, // CreateStackWithCap with stackCapLimit=-1, and the anon vault-reject loop // (no vault refs here → passes). func TestStackNew_Anonymous_Succeeds(t *testing.T) { diff --git a/internal/handlers/stack_test.go b/internal/handlers/stack_test.go index 2e79c1e7..395aec78 100644 --- a/internal/handlers/stack_test.go +++ b/internal/handlers/stack_test.go @@ -24,6 +24,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" @@ -455,7 +456,8 @@ services: } // TestStackNew_Anonymous_Returns202 verifies that POST /stacks/new without auth -// returns 202 with tier=anonymous and expires_in=24h — same model as /db/new, /cache/new. +// returns 202 with tier=anonymous and expires_in=6h. A stack is live compute, so +// the anon window is tighter than the 24h anon-resource data TTL (anonymousStackTTL). func TestStackNew_Anonymous_Returns202(t *testing.T) { requireTestDB(t) db, cleanDB := testhelpers.SetupTestDB(t) @@ -485,10 +487,12 @@ func TestStackNew_Anonymous_Returns202(t *testing.T) { assert.True(t, body.OK) assert.NotEmpty(t, body.StackID) assert.Equal(t, "anonymous", body.Tier) - assert.Equal(t, "24h", body.ExpiresIn) + assert.Equal(t, "6h", body.ExpiresIn, + "anonymous stack TTL must be 6h (anonymousStackTTL), not the 24h data-resource TTL") + assert.Contains(t, body.Note, "6h", "note must state the 6h anon-stack window") assert.Contains(t, body.Note, "api.instanode.dev/start", "upgrade URL must appear in note") - // Verify DB: stack has nil team_id and non-nil expires_at. + // Verify DB: stack has nil team_id and expires_at ≈ now()+6h. var teamIDNull sql.NullString var expiresAtNull sql.NullTime require.NoError(t, @@ -497,7 +501,12 @@ func TestStackNew_Anonymous_Returns202(t *testing.T) { ).Scan(&teamIDNull, &expiresAtNull), ) assert.False(t, teamIDNull.Valid, "anonymous stack must have NULL team_id") - assert.True(t, expiresAtNull.Valid, "anonymous stack must have non-NULL expires_at") + require.True(t, expiresAtNull.Valid, "anonymous stack must have non-NULL expires_at") + // Allow a 5-minute skew for test-execution + clock drift; the point is + // "6h not 24h", so a generous window still fails loudly on a regression. + delta := time.Until(expiresAtNull.Time) + assert.InDelta(t, (6 * time.Hour).Seconds(), delta.Seconds(), 300, + "anonymous stack expires_at must be ≈ now()+6h") } // TestStackGet_NotFound verifies that GET /stacks/nonexistent returns 404.