diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index 8266409..be80a9d 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -614,6 +614,45 @@ const openAPISpec = `{ } } }, + "/api/v1/billing/usage": { + "get": { + "summary": "Aggregated usage metrics for the authenticated team (cached)", + "description": "One-shot fetch that powers the dashboard's BillingPage Usage panel. Replaces the prior pattern of summing storage_bytes per type in the browser after pulling the full /resources list. The aggregation runs once per team per 30s cache window and is shared across every surface (BillingPage today, future MCP agent_usage_summary tool). Real-time provisioning paths (POST /db/new etc.) MUST NOT use this aggregate — they read fresh DB state. Response shape: { ok, freshness_seconds, as_of, usage: { postgres, redis, mongodb, deployments, webhooks, vault, members } }. Storage services carry { bytes, limit_bytes }; count services carry { count, limit }. -1 in any limit field means 'unlimited' (matches plans.yaml). Cache-Control: private, max-age=30, stale-while-revalidate=60 — browsers + intermediate proxies honour the same window without hammering the API.", + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "Aggregated usage payload", + "headers": { + "Cache-Control": { + "schema": { "type": "string", "example": "private, max-age=30, stale-while-revalidate=60" }, + "description": "Per-team payload — private (no shared proxies). 30s max-age matches the server-side cache; 60s SWR gives the browser a grace window where stale values render while a background refresh runs." + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/BillingUsageResponse" }, + "example": { + "ok": true, + "freshness_seconds": 30, + "as_of": "2026-05-12T00:00:00Z", + "usage": { + "postgres": { "bytes": 12582912, "limit_bytes": 524288000 }, + "redis": { "bytes": 0, "limit_bytes": 26214400 }, + "mongodb": { "bytes": 0, "limit_bytes": 104857600 }, + "deployments": { "count": 1, "limit": 1 }, + "webhooks": { "count": 3, "limit": 1000 }, + "vault": { "count": 5, "limit": 50 }, + "members": { "count": 1, "limit": 1 } + } + } + } + } + }, + "401": { "description": "Missing or invalid session token. Response includes agent_action pointing the user at https://instanode.dev/login.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "500": { "description": "Failed to compute usage (transient DB error). Retry with backoff.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + } + } + }, "/metrics": { "get": { "summary": "Prometheus metrics scrape endpoint", @@ -821,6 +860,43 @@ const openAPISpec = `{ } } }, + "/api/v1/team/summary": { + "get": { + "summary": "Aggregated team counts for the dashboard sidebar (cached)", + "description": "One-shot fetch the dashboard sidebar uses to render SidebarUpgradeCard + per-nav-row badge numbers (Resources · 7, Deployments · 2, etc.). Replaces the prior pattern where every page-load triggered its own /api/v1/resources scan to compute a single number. Aggregation runs once per team per 5-min cache window — long enough that one signed-in user opening every dashboard page across a session triggers ~1 aggregate per surface, short enough that a provision/delete is visible within minutes. Eventual-consistent by design (per the §13 freshness matrix); do NOT use this for quota gate decisions. Response shape: { ok, freshness_seconds, as_of, tier, counts: { resources: { total, postgres, redis, mongodb, webhook, queue, storage, other }, deployments, members, vault_keys } }. Unknown resource_type rows fold into counts.resources.other so the total stays accurate even when the per-type breakdown lags a newly-shipped service. Cache-Control: private, max-age=300.", + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "Aggregated team summary", + "headers": { + "Cache-Control": { + "schema": { "type": "string", "example": "private, max-age=300" }, + "description": "Per-team payload — private (no shared proxies). 5-min max-age matches the server-side cache. No stale-while-revalidate because the window is already wide." + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/TeamSummaryResponse" }, + "example": { + "ok": true, + "freshness_seconds": 300, + "as_of": "2026-05-12T00:00:00Z", + "tier": "hobby", + "counts": { + "resources": { "total": 7, "postgres": 2, "redis": 1, "mongodb": 1, "webhook": 2, "queue": 0, "storage": 1, "other": 0 }, + "deployments": 1, + "members": 1, + "vault_keys": 5 + } + } + } + } + }, + "401": { "description": "Missing or invalid session token. Response includes agent_action pointing the user at https://instanode.dev/login.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "500": { "description": "Failed to compute summary (transient DB error). Retry with backoff.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + } + } + }, "/api/v1/team/members": { "get": { "summary": "List members of the caller's team", @@ -949,13 +1025,60 @@ const openAPISpec = `{ "/api/v1/stacks": { "get": { "summary": "List all stacks owned by the caller's team", + "description": "Returns one row per stack, including its env (production/staging/dev/...) and parent_stack_id linkage so the dashboard can render the Environments grid without an extra round-trip per stack. For grouped env-sibling views call GET /api/v1/stacks/{slug}/family instead.", "security": [{ "bearerAuth": [] }], "responses": { - "200": { "description": "Stack list", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "items": { "type": "array", "items": { "type": "object", "properties": { "stack_id": { "type": "string", "description": "Slug (same as path /stacks/{slug})" }, "name": { "type": "string" }, "status": { "type": "string" }, "tier": { "type": "string" }, "namespace": { "type": "string" }, "created_at": { "type": "string", "format": "date-time" } } } }, "total": { "type": "integer" } } } } } }, + "200": { "description": "Stack list", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "items": { "type": "array", "items": { "type": "object", "properties": { "stack_id": { "type": "string", "description": "Slug (same as path /stacks/{slug})" }, "name": { "type": "string" }, "status": { "type": "string" }, "tier": { "type": "string" }, "namespace": { "type": "string" }, "env": { "type": "string", "description": "Deployment env (production / staging / dev / ...). Defaults to 'production' for legacy stacks pre-dating migration 015." }, "parent_stack_id": { "type": "string", "description": "Root stack id when this is a promoted child. Empty string for the root." }, "created_at": { "type": "string", "format": "date-time" } } } }, "total": { "type": "integer" } } } } } }, "401": { "description": "Unauthorized" } } } }, + "/api/v1/stacks/{slug}/family": { + "get": { + "summary": "Get every env sibling of a stack (Pro+)", + "description": "Returns the production / staging / dev variants of the same app as a flat list, with the root first. The 'family' is resolved by walking parent_stack_id up to the root, then collecting every direct child. Pro / Team / Growth only — Hobby callers receive 402 with agent_action because they can't create siblings. Includes a per-env URL derived from the primary exposed service's app_url so the dashboard can render clickable env tiles. Response carries Cache-Control: private, max-age=60 — short enough to stay fresh across promotes/redeploys.", + "security": [{ "bearerAuth": [] }], + "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Any member of the family (root or child) — the handler walks up to the root and back down." }], + "responses": { + "200": { + "description": "Family list (root first)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { "type": "boolean" }, + "slug": { "type": "string", "description": "Echo of the requested slug." }, + "family": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { "type": "string" }, + "name": { "type": "string" }, + "env": { "type": "string" }, + "status": { "type": "string" }, + "tier": { "type": "string" }, + "url": { "type": "string", "description": "Best-effort: first exposed service's app_url, else first service URL, else empty." }, + "is_root": { "type": "boolean", "description": "True for the family root (parent_stack_id is null)." }, + "parent_stack_id": { "type": "string", "description": "Empty string for the root; otherwise the root's id." }, + "last_deploy_at": { "type": "string", "format": "date-time" }, + "created_at": { "type": "string", "format": "date-time" } + } + } + }, + "total": { "type": "integer" } + } + } + } + } + }, + "401": { "description": "Unauthorized — session required" }, + "402": { "description": "Upgrade required — team is not on pro/team/growth. Response carries upgrade_url + agent_action." }, + "404": { "description": "Stack not found or not owned by this team" } + } + } + }, "/api/v1/stacks/{slug}/domains": { "post": { "summary": "Bind a custom hostname to a stack (Pro+)", @@ -1413,6 +1536,76 @@ const openAPISpec = `{ }, "required": ["ok", "tier", "subscription_status", "billing_email"] }, + "BillingUsageResponse": { + "type": "object", + "description": "Cached aggregate served by GET /api/v1/billing/usage. Replaces the prior client-side summation across /resources. Shared payload type for the cache layer (Redis JSON) and the public HTTP response, so a deploy-time shape change naturally invalidates older cache entries. -1 in any limit_bytes / limit field means 'unlimited' (matches the plans.yaml convention).", + "properties": { + "ok": { "type": "boolean", "enum": [true] }, + "freshness_seconds": { "type": "integer", "description": "Cache TTL window in seconds. Today 30 — matches the §13 freshness target and the Cache-Control max-age. Tune in one place: this field follows the server-side const." }, + "as_of": { "type": "string", "format": "date-time", "description": "When the aggregation was computed. Useful for stale-while-revalidate displays and for debugging cache-vs-live discrepancies." }, + "usage": { + "type": "object", + "description": "Per-service metrics. Storage services carry { bytes, limit_bytes }. Count services carry { count, limit }. Fields are omitempty so the irrelevant one for each kind stays off the wire.", + "properties": { + "postgres": { "$ref": "#/components/schemas/UsageMetric" }, + "redis": { "$ref": "#/components/schemas/UsageMetric" }, + "mongodb": { "$ref": "#/components/schemas/UsageMetric" }, + "deployments": { "$ref": "#/components/schemas/UsageMetric" }, + "webhooks": { "$ref": "#/components/schemas/UsageMetric" }, + "vault": { "$ref": "#/components/schemas/UsageMetric" }, + "members": { "$ref": "#/components/schemas/UsageMetric" } + } + } + }, + "required": ["ok", "freshness_seconds", "as_of", "usage"] + }, + "UsageMetric": { + "type": "object", + "description": "One service's slice of the usage aggregate. Either bytes/limit_bytes (storage services) or count/limit (deployments, webhooks, vault, members). -1 in a limit field means 'unlimited'.", + "properties": { + "bytes": { "type": "integer", "format": "int64", "description": "Current storage usage in bytes. Present on postgres/redis/mongodb." }, + "limit_bytes": { "type": "integer", "format": "int64", "description": "Storage cap in bytes (plans.yaml storage_mb × 1024 × 1024). -1 = unlimited." }, + "count": { "type": "integer", "description": "Current count. Present on deployments/webhooks/vault/members." }, + "limit": { "type": "integer", "description": "Count cap from plans.yaml. -1 = unlimited." } + } + }, + "TeamSummaryResponse": { + "type": "object", + "description": "Cached aggregate served by GET /api/v1/team/summary. Powers the dashboard sidebar's SidebarUpgradeCard and per-nav-row badge numbers. Eventual-consistent on purpose (5-min window) — do NOT use for quota gate decisions. Shared payload type for the Redis cache and the public response; a JSON shape change naturally invalidates older cache entries.", + "properties": { + "ok": { "type": "boolean", "enum": [true] }, + "freshness_seconds": { "type": "integer", "description": "Cache TTL window in seconds. Today 300 — matches the server-side const and the Cache-Control max-age." }, + "as_of": { "type": "string", "format": "date-time", "description": "When the aggregation was computed." }, + "tier": { "type": "string", "description": "Current plan tier from the team record. Mirrored here so the sidebar doesn't need a second /billing fetch just to render the upgrade card.", "enum": ["anonymous", "free", "hobby", "pro", "team"] }, + "counts": { + "type": "object", + "description": "Per-area counts. resources.total is the sum of every typed bucket plus 'other' — saves the dashboard from re-adding.", + "properties": { + "resources": { "$ref": "#/components/schemas/TeamSummaryResourceCounts" }, + "deployments": { "type": "integer", "description": "Active deployments. Excludes status IN ('deleted','stopped') — matches the dashboard's 'active deployments' framing." }, + "members": { "type": "integer", "description": "Team member count (including the caller)." }, + "vault_keys": { "type": "integer", "description": "Total vault entries across every env this team owns." } + }, + "required": ["resources", "deployments", "members", "vault_keys"] + } + }, + "required": ["ok", "freshness_seconds", "as_of", "tier", "counts"] + }, + "TeamSummaryResourceCounts": { + "type": "object", + "description": "Per-type breakdown of active resources for one team. Produced by a single SELECT resource_type, COUNT(*) GROUP BY resource_type — cheaper than six separate COUNTs. Unknown resource_type rows fold into 'other' so the total stays accurate when a freshly-shipped service hasn't gotten a typed bucket yet.", + "properties": { + "total": { "type": "integer", "description": "Sum across every bucket (typed + other)." }, + "postgres": { "type": "integer" }, + "redis": { "type": "integer" }, + "mongodb": { "type": "integer" }, + "webhook": { "type": "integer" }, + "queue": { "type": "integer" }, + "storage": { "type": "integer" }, + "other": { "type": "integer", "description": "Catch-all for resource_type values this build doesn't recognise (e.g. a service shipped after the dashboard's TS types were generated). Always included in total." } + }, + "required": ["total"] + }, "ErrorResponse": { "type": "object", "description": "Canonical JSON shape returned by every 4xx/5xx response. agent_action and upgrade_url are populated for error codes where the calling agent benefits from user-facing copy or a remediation link (quota walls, invalid tokens, expired resources, permission denied, tier gates). Codes without remediation guidance (transient db_error, list_failed, stream_failed, etc.) omit these fields. Backward-compatible: omitempty fields are absent on the wire when empty so existing clients that ignored agent_action/upgrade_url see no change.", diff --git a/internal/handlers/openapi_test.go b/internal/handlers/openapi_test.go index 6bd34f6..b271c0a 100644 --- a/internal/handlers/openapi_test.go +++ b/internal/handlers/openapi_test.go @@ -213,6 +213,82 @@ func TestOpenAPI_ErrorResponseSchemaDocumented(t *testing.T) { } } +// TestOpenAPI_CachedAggregateEndpointsDocumented guards Wave 4-L: the two +// cached aggregate endpoints (/api/v1/billing/usage and /api/v1/team/summary) +// are live and tested in production but were undocumented in the OpenAPI +// spec until this fix. Agents reading /openapi.json alone now have a +// machine-readable signal that the cached aggregates exist + what their +// payload shapes look like, so they can pull dashboard-style metrics +// without falling back to scanning the full /resources list. +func TestOpenAPI_CachedAggregateEndpointsDocumented(t *testing.T) { + var v map[string]any + if err := json.Unmarshal([]byte(openAPISpec), &v); err != nil { + t.Fatalf("openAPISpec parse: %v", err) + } + paths, _ := v["paths"].(map[string]any) + for _, p := range []string{ + "/api/v1/billing/usage", + "/api/v1/team/summary", + } { + op, ok := paths[p].(map[string]any) + if !ok { + t.Errorf("OpenAPI is missing path %q — agents cannot discover the cached aggregate endpoints", p) + continue + } + get, ok := op["get"].(map[string]any) + if !ok { + t.Errorf("path %q missing GET operation", p) + continue + } + // Both endpoints are session-gated; if bearerAuth gets dropped from + // the security stanza, a dashboard refactor probably ripped the auth + // requirement out by accident. + sec, _ := get["security"].([]any) + if len(sec) == 0 { + t.Errorf("path %q GET must declare bearerAuth — these endpoints require a session JWT", p) + } + // 200 response must reference a schema and document the Cache-Control + // header — that's the whole point of these endpoints, and an agent + // reading the spec needs to know they're cache-friendly. + responses, _ := get["responses"].(map[string]any) + r200, ok := responses["200"].(map[string]any) + if !ok { + t.Errorf("path %q must document a 200 response with the cached payload schema", p) + continue + } + headers, _ := r200["headers"].(map[string]any) + if _, ok := headers["Cache-Control"].(map[string]any); !ok { + t.Errorf("path %q 200 response must document the Cache-Control header so agents know the response is cacheable", p) + } + body, _ := digMap(r200, "content", "application/json") + schemaRef, _ := body["schema"].(map[string]any) + ref, _ := schemaRef["$ref"].(string) + if ref == "" { + t.Errorf("path %q 200 must $ref a response schema", p) + } + } + + // Schemas must be present + carry the canonical aggregate fields. + if props, ok := digMap(v, "components", "schemas", "BillingUsageResponse", "properties"); ok { + for _, k := range []string{"ok", "freshness_seconds", "as_of", "usage"} { + if _, ok := props[k]; !ok { + t.Errorf("BillingUsageResponse.properties.%s missing — agents lose the cache-window contract", k) + } + } + } else { + t.Error("components.schemas.BillingUsageResponse missing") + } + if props, ok := digMap(v, "components", "schemas", "TeamSummaryResponse", "properties"); ok { + for _, k := range []string{"ok", "freshness_seconds", "as_of", "tier", "counts"} { + if _, ok := props[k]; !ok { + t.Errorf("TeamSummaryResponse.properties.%s missing — agents lose the cache-window contract", k) + } + } + } else { + t.Error("components.schemas.TeamSummaryResponse missing") + } +} + func digMap(root map[string]any, keys ...string) (map[string]any, bool) { cur := root for _, k := range keys { diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 161e672..97a86d7 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -27,6 +27,46 @@ const ( audienceMismatchError = "invalid_token" ) +// AuthLoginURL is the URL agents should show users when their session +// token is rejected. Exposed as a package-level variable so tests and +// self-hosted operators can override it. Mirrors handlers.DefaultLoginURL — +// duplicated rather than imported because the handlers package consumes +// middleware (not the other way around), and a circular import would +// otherwise be required to share the constant. +var AuthLoginURL = "https://instanode.dev/login" + +// unauthorizedAgentAction is the canonical agent_action sentence served on +// every 401 from RequireAuth. Mirrors the "unauthorized" entry in +// handlers.codeToAgentAction so an agent inspecting either a handler-emitted +// 401 (e.g. a stale session bouncing off /api/v1/billing/usage) or a +// middleware-emitted 401 (e.g. no Authorization header at all) gets the same +// remediation prose either way. +const unauthorizedAgentAction = "The user's INSTANODE_TOKEN is invalid or expired. Have them log in at https://instanode.dev/login to mint a new one." + +// respondUnauthorized writes the canonical 401 body shape used by RequireAuth: +// +// { +// "ok": false, +// "error": "unauthorized", +// "agent_action": "The user's INSTANODE_TOKEN is invalid or expired...", +// "upgrade_url": "https://instanode.dev/login" +// } +// +// agent_action is the verbatim sentence the calling agent should surface to +// the human user, per the §10.15 agent-action contract. upgrade_url points +// at the login page because re-auth is the remediation for every variant of +// this error (no header, malformed JWT, expired JWT, wrong secret, missing +// claims, invalid PAT). Kept as a single helper so adding RFC 6750 +// WWW-Authenticate headers in a future PR happens in one place. +func respondUnauthorized(c *fiber.Ctx) error { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "ok": false, + "error": "unauthorized", + "agent_action": unauthorizedAgentAction, + "upgrade_url": AuthLoginURL, + }) +} + // defaultCanonicalResourceURL is the audience used when neither API_PUBLIC_URL // nor the live request host is available. Aliased to urls.PublicAPIBase to // keep the literal "https://api.instanode.dev" in exactly one place. @@ -133,15 +173,28 @@ func rejectAudienceMismatch(c *fiber.Ctx) error { // RequireAuth validates the Authorization: Bearer {jwt} header. // On success it stores user_id and team_id in fiber.Locals and calls Next. -// On failure it returns 401 { ok: false, error: "unauthorized" }. +// +// On failure it returns 401 with the canonical agent-action body shape: +// +// { +// "ok": false, +// "error": "unauthorized", +// "agent_action": "The user's INSTANODE_TOKEN is invalid or expired...", +// "upgrade_url": "https://instanode.dev/login" +// } +// +// agent_action mirrors the "unauthorized" entry in handlers.codeToAgentAction +// so a Claude / Cursor / MCP agent inspecting any 401 from this API gets the +// same remediation prose whether the rejection happened in this middleware +// or in a downstream handler (e.g. a session that decoded but had stale +// claims). Audience-mismatch responses (RFC 8707) still go through +// rejectAudienceMismatch and keep their distinct `invalid_token` error +// keyword so agents can branch "wrong server" from "bad credentials". func RequireAuth(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { header := c.Get("Authorization") if len(header) < 8 || header[:7] != "Bearer " { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "ok": false, - "error": "unauthorized", - }) + return respondUnauthorized(c) } tokenStr := header[7:] @@ -151,10 +204,7 @@ func RequireAuth(cfg *config.Config) fiber.Handler { if IsAPIKey(tokenStr) { ok, err := AuthenticateAPIKey(c, tokenStr) if err != nil || !ok { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "ok": false, - "error": "unauthorized", - }) + return respondUnauthorized(c) } return c.Next() } @@ -167,17 +217,11 @@ func RequireAuth(cfg *config.Config) fiber.Handler { return []byte(cfg.JWTSecret), nil }) if err != nil || !parsed.Valid { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "ok": false, - "error": "unauthorized", - }) + return respondUnauthorized(c) } if claims.UserID == "" || claims.TeamID == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "ok": false, - "error": "unauthorized", - }) + return respondUnauthorized(c) } // RFC 8707 audience check — only enforced when the token actually diff --git a/internal/middleware/auth_agent_action_test.go b/internal/middleware/auth_agent_action_test.go new file mode 100644 index 0000000..972719b --- /dev/null +++ b/internal/middleware/auth_agent_action_test.go @@ -0,0 +1,196 @@ +package middleware_test + +// auth_agent_action_test.go — agent_action contract tests for RequireAuth. +// +// RETRO-2026-05-12 fix: an unauthenticated call to /api/v1/resources (and +// every other RequireAuth-gated endpoint) was returning the bare three-key +// shape `{ok:false, error:"unauthorized"}` — no agent_action, no upgrade_url. +// Downstream handlers that go through respondError already emit the +// agent_action sentence for the "unauthorized" code (via codeToAgentAction), +// but middleware bypasses that helper to avoid a circular import. The fix +// inlines the same prose + login URL directly in respondUnauthorized so an +// agent inspecting any 401 from this API gets the same remediation guidance +// regardless of which layer rejected the request. +// +// These tests live in their own file so they don't pull internal/testhelpers +// (which transitively imports internal/handlers and would risk import +// cycles with unrelated changes in that package). + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/middleware" +) + +// agentActionTestJWTSecret is intentionally distinct from audTestJWTSecret +// (auth_audience_test.go) so a token signed in this file can be used as the +// "wrong secret" probe — RequireAuth rejecting a wrong-secret token must +// produce the same agent_action shape as a token-shaped-but-not-bearer +// rejection. +const agentActionTestJWTSecret = "agent-action-secret-32-bytes-min-test!!!" + +// newAgentActionApp builds a minimal Fiber app with RequireAuth gating one +// route. The route never runs on failure — every assertion below targets +// the 401 body shape RequireAuth itself emits. +func newAgentActionApp() *fiber.App { + cfg := &config.Config{JWTSecret: agentActionTestJWTSecret} + app := fiber.New() + app.Get("/api/v1/resources", + middleware.RequireAuth(cfg), + func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"ok": true}) + }, + ) + return app +} + +// signValidSession produces a JWT that would normally pass RequireAuth. +// Used to build "negative" tokens (wrong secret, expired) where the token +// must be syntactically valid but logically rejected. +func signValidSession(t *testing.T, secret string, expiry time.Duration) string { + t.Helper() + type sessionClaims struct { + UserID string `json:"uid"` + TeamID string `json:"tid"` + Email string `json:"email"` + jwt.RegisteredClaims + } + c := sessionClaims{ + UserID: uuid.NewString(), + TeamID: uuid.NewString(), + Email: "test@instant.dev", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), + ID: uuid.NewString(), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, c) + signed, err := tok.SignedString([]byte(secret)) + require.NoError(t, err) + return signed +} + +// assertAgentActionUnauthorized asserts the canonical 401 body shape served +// by respondUnauthorized: error="unauthorized", agent_action mentions login, +// upgrade_url points at the login page. Centralised so the table-test cases +// below don't repeat the assertions and the contract stays in one place. +func assertAgentActionUnauthorized(t *testing.T, resp *http.Response) { + t.Helper() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + + assert.Equal(t, false, body["ok"]) + assert.Equal(t, "unauthorized", body["error"], + "middleware-emitted 401 must use the same 'unauthorized' code that handlers.codeToAgentAction matches on") + + action, ok := body["agent_action"].(string) + require.True(t, ok, "agent_action must be a string field on every 401 from RequireAuth — this is the whole point of the fix") + assert.NotEmpty(t, action, "agent_action must be populated, not just present") + assert.Contains(t, action, "INSTANODE_TOKEN", + "agent_action must name the env var the user sets — otherwise the agent has nothing concrete to mention") + assert.Contains(t, action, "https://instanode.dev/login", + "agent_action must include the login URL inline so the agent's prose carries the link without a second lookup") + + url, ok := body["upgrade_url"].(string) + require.True(t, ok, "upgrade_url must be present so MPP-style agents can follow it programmatically") + assert.Equal(t, "https://instanode.dev/login", url, + "upgrade_url for 'unauthorized' must point at the login page, not pricing") +} + +// TestRequireAuth_NoHeader_EmitsAgentAction — the bare-call case. Before the +// fix this returned {ok:false, error:"unauthorized"} only. After the fix it +// carries the full agent_action body shape. +func TestRequireAuth_NoHeader_EmitsAgentAction(t *testing.T) { + app := newAgentActionApp() + req := httptest.NewRequest(http.MethodGet, "/api/v1/resources", nil) + resp, err := app.Test(req, 1000) + require.NoError(t, err) + defer resp.Body.Close() + + assertAgentActionUnauthorized(t, resp) +} + +// TestRequireAuth_MalformedBearer_EmitsAgentAction — non-"Bearer " prefix. +// Same shape as no-header. +func TestRequireAuth_MalformedBearer_EmitsAgentAction(t *testing.T) { + app := newAgentActionApp() + req := httptest.NewRequest(http.MethodGet, "/api/v1/resources", nil) + req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") // wrong scheme + resp, err := app.Test(req, 1000) + require.NoError(t, err) + defer resp.Body.Close() + + assertAgentActionUnauthorized(t, resp) +} + +// TestRequireAuth_InvalidJWT_EmitsAgentAction — garbage after "Bearer ". +// JWT parse fails; agent_action shape preserved. +func TestRequireAuth_InvalidJWT_EmitsAgentAction(t *testing.T) { + app := newAgentActionApp() + req := httptest.NewRequest(http.MethodGet, "/api/v1/resources", nil) + req.Header.Set("Authorization", "Bearer not-a-real-jwt-token") + resp, err := app.Test(req, 1000) + require.NoError(t, err) + defer resp.Body.Close() + + assertAgentActionUnauthorized(t, resp) +} + +// TestRequireAuth_WrongSecret_EmitsAgentAction — a syntactically valid JWT +// signed with a different secret. ParseWithClaims fails verification. +func TestRequireAuth_WrongSecret_EmitsAgentAction(t *testing.T) { + tok := signValidSession(t, "completely-different-secret-32-bytes-here!!!", time.Hour) + + app := newAgentActionApp() + req := httptest.NewRequest(http.MethodGet, "/api/v1/resources", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := app.Test(req, 1000) + require.NoError(t, err) + defer resp.Body.Close() + + assertAgentActionUnauthorized(t, resp) +} + +// TestRequireAuth_ExpiredJWT_EmitsAgentAction — a JWT signed with the right +// secret but already past its exp. The "expired" case is the most common +// in production (users come back to the dashboard after a few days); the +// agent_action prose is the same as every other 401. +func TestRequireAuth_ExpiredJWT_EmitsAgentAction(t *testing.T) { + tok := signValidSession(t, agentActionTestJWTSecret, -time.Hour) + + app := newAgentActionApp() + req := httptest.NewRequest(http.MethodGet, "/api/v1/resources", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := app.Test(req, 1000) + require.NoError(t, err) + defer resp.Body.Close() + + assertAgentActionUnauthorized(t, resp) +} + +// TestRequireAuth_BearerOnly_EmitsAgentAction — "Bearer " literal with no +// token after it. The 8-byte length guard short-circuits before any JWT +// parsing. +func TestRequireAuth_BearerOnly_EmitsAgentAction(t *testing.T) { + app := newAgentActionApp() + req := httptest.NewRequest(http.MethodGet, "/api/v1/resources", nil) + req.Header.Set("Authorization", "Bearer ") // space but no token + resp, err := app.Test(req, 1000) + require.NoError(t, err) + defer resp.Body.Close() + + assertAgentActionUnauthorized(t, resp) +}