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
27 changes: 20 additions & 7 deletions internal/handlers/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 ""
}(),
Expand Down
2 changes: 1 addition & 1 deletion internal/handlers/stack_deployasync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
17 changes: 13 additions & 4 deletions internal/handlers/stack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"net/http/httptest"
"os"
"testing"
"time"

"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
Loading