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
2 changes: 2 additions & 0 deletions internal/handlers/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error {
"note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
"claim_url": upgradeURL, // DOG-21: see db.go
}
setInternalURL(dedupResp, existing.Tier, connectionURL, "redis")
if existing.KeyPrefix.String != "" {
Expand Down Expand Up @@ -289,6 +290,7 @@ func (h *CacheHandler) NewCache(c *fiber.Ctx) error {
"note": upgradeNote(upgradeURL),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
"claim_url": upgradeURL, // DOG-21: see dedup branch above
}
// T19 P0-2 (BugHunt 2026-05-20): emit top-level expires_at for
// shape parity with storage/webhook responses; see db.go for rationale.
Expand Down
7 changes: 7 additions & 0 deletions internal/handlers/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ func (h *DBHandler) NewDB(c *fiber.Ctx) error {
"note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
// DOG-21 (QA 2026-05-29): emit claim_url alongside upgrade
// so agents have a labelled link for the email-claim flow.
// Same URL (the /start?t=<jwt> page handles both claim and
// upgrade); distinct field signals intent to the calling
// agent, matching the documented OpenAPI response schema.
"claim_url": upgradeURL,
}
setInternalURL(dedupResp, existing.Tier, connectionURL, "postgres")
return respondOK(c, dedupResp)
Expand Down Expand Up @@ -331,6 +337,7 @@ func (h *DBHandler) NewDB(c *fiber.Ctx) error {
"note": upgradeNote(upgradeURL),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
"claim_url": upgradeURL, // DOG-21: see dedup branch above
}
// T19 P0-2 (BugHunt 2026-05-20): unify the TTL contract across all
// provisioning endpoints — storage/webhook already emit a top-level
Expand Down
90 changes: 90 additions & 0 deletions internal/handlers/dog21_claim_url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package handlers

// dog21_claim_url_test.go — DOG-21 regression.
//
// Every anonymous-tier 201 provision response (db/cache/nosql/queue/
// vector/webhook/storage) MUST emit a top-level `claim_url` alongside
// `upgrade` / `upgrade_jwt`. Pre-fix the field was documented on the
// 402 recycle-gate envelope but absent on the 201, so agents that
// wanted to surface a claim CTA had to hand-construct the URL from
// upgrade_jwt — breaking the contract across vendor integrations.
//
// Source-level regression so we don't need a live Postgres/Redis to
// pin the contract. The full integration test path lives in
// existing service tests (provarms_test.go etc.).

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// anonProvisionHandlerFiles lists every file that emits a 201 anonymous
// provision response. Iterating this list (rule 18 — registry not
// hand-typed assertions) means a new service handler added to the same
// shape automatically gets the same coverage requirement.
var anonProvisionHandlerFiles = []string{
"db.go",
"cache.go",
"nosql.go",
"queue.go",
"vector.go",
"webhook.go",
"storage.go",
}

// TestDOG21_ClaimURLEmittedOnEveryAnonProvision asserts every anon
// provision handler file emits at least one `claim_url` field. The
// number of expected emissions per file is at least the number of
// `upgrade_jwt` emissions in that file (one per code path: the
// fingerprint-dedup branch and the fresh-201 branch each emit one).
func TestDOG21_ClaimURLEmittedOnEveryAnonProvision(t *testing.T) {
for _, fname := range anonProvisionHandlerFiles {
t.Run(fname, func(t *testing.T) {
path := filepath.Join(".", fname)
raw, err := os.ReadFile(path)
require.NoError(t, err, "must read handler file %s", path)
src := string(raw)

// Every site that emits upgrade_jwt is an anon-201 site. The
// pre-fix code emitted upgrade_jwt without claim_url. The fix
// requires claim_url at every such site.
jwtCount := strings.Count(src, "upgrade_jwt")
claimCount := strings.Count(src, "claim_url")

require.Greater(t, jwtCount, 0,
"DOG-21: %s should still have anon-provision code paths that emit upgrade_jwt", fname)
assert.GreaterOrEqual(t, claimCount, jwtCount,
"DOG-21: %s emits upgrade_jwt %d time(s) but claim_url only %d time(s) — every 201 anon response must carry claim_url",
fname, jwtCount, claimCount)
})
}
}

// TestDOG21_OpenAPISchemaDocumentsClaimURLOnEveryAnonResponse pins
// the rule-22 surface checklist: every provision-response schema in
// openapi.go must describe the new claim_url field.
func TestDOG21_OpenAPISchemaDocumentsClaimURL(t *testing.T) {
raw, err := os.ReadFile("openapi.go")
require.NoError(t, err)
src := string(raw)

// At least 7 claim_url description lines (one per service schema +
// the ErrorResponse schema = 8 minimum). We use 7 as the floor
// because counting in openapi.go is fragile against future renames;
// 7 catches the "added to only some schemas" failure mode without
// flaking on cosmetic JSON splitting.
claimURLDescCount := strings.Count(src, `"claim_url":`)
assert.GreaterOrEqual(t, claimURLDescCount, 7,
"DOG-21: openapi.go must document claim_url across all 7 provision-response schemas + ErrorResponse (rule 22 surface checklist)")

// The widened ErrorResponse description must call out the new 201 emit
// surface so an agent reading the doc knows to expect claim_url on
// success too, not only the recycle-gate 402.
assert.Contains(t, src, "ALSO emitted on every successful 201 anonymous provision",
"DOG-21: ErrorResponse.claim_url description must reflect the 201-anon emit (the documentation gap that caused the original report)")
}
2 changes: 2 additions & 0 deletions internal/handlers/nosql.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ func (h *NoSQLHandler) NewNoSQL(c *fiber.Ctx) error {
"note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
"claim_url": upgradeURL, // DOG-21: see db.go
}
setInternalURL(dedupResp, existing.Tier, connectionURL, "mongodb")
return respondOK(c, dedupResp)
Expand Down Expand Up @@ -285,6 +286,7 @@ func (h *NoSQLHandler) NewNoSQL(c *fiber.Ctx) error {
"note": upgradeNote(upgradeURL),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
"claim_url": upgradeURL, // DOG-21: see dedup branch above
}
// T19 P0-2 (BugHunt 2026-05-20): emit top-level expires_at for
// shape parity with storage/webhook responses; see db.go for rationale.
Expand Down
23 changes: 15 additions & 8 deletions internal/handlers/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -2644,7 +2644,8 @@ const openAPISpec = `{
"warning": { "type": "string", "description": "Present only when the resource is already over its storage limit at provision time — accompanied by the X-Instant-Notice: storage_limit_reached response header." },
"note": { "type": "string" },
"upgrade_jwt": { "type": "string", "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email to convert the anonymous resource into a claimed (authenticated) one — no need to string-parse the upgrade URL. Absent on authenticated provisions." },
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL the agent can hand to the user to drive the dashboard claim flow." }
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL the agent can hand to the user to drive the dashboard claim flow." },
"claim_url": { "type": "string", "format": "uri", "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt." }
}
},
"VectorProvisionRequest": {
Expand Down Expand Up @@ -2677,7 +2678,8 @@ const openAPISpec = `{
"warning": { "type": "string", "description": "Present only when the resource is already over its storage limit at provision time — accompanied by the X-Instant-Notice: storage_limit_reached response header." },
"note": { "type": "string" },
"upgrade_jwt": { "type": "string", "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions." },
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." }
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." },
"claim_url": { "type": "string", "format": "uri", "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt." }
}
},
"CacheProvisionResponse": {
Expand All @@ -2699,7 +2701,8 @@ const openAPISpec = `{
"warning": { "type": "string", "description": "Present only when the resource is already over its storage limit at provision time — accompanied by the X-Instant-Notice: storage_limit_reached response header." },
"note": { "type": "string" },
"upgrade_jwt": { "type": "string", "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions." },
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." }
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." },
"claim_url": { "type": "string", "format": "uri", "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt." }
}
},
"NoSQLProvisionResponse": {
Expand All @@ -2720,7 +2723,8 @@ const openAPISpec = `{
"warning": { "type": "string", "description": "Present only when the resource is already over its storage limit at provision time — accompanied by the X-Instant-Notice: storage_limit_reached response header." },
"note": { "type": "string" },
"upgrade_jwt": { "type": "string", "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions." },
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." }
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." },
"claim_url": { "type": "string", "format": "uri", "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt." }
}
},
"QueueProvisionResponse": {
Expand Down Expand Up @@ -2754,7 +2758,8 @@ const openAPISpec = `{
"dedicated": { "type": "boolean", "description": "True when the resource was provisioned on dedicated (single-tenant) infrastructure rather than the shared pool." },
"note": { "type": "string" },
"upgrade_jwt": { "type": "string", "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions." },
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." }
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." },
"claim_url": { "type": "string", "format": "uri", "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt." }
}
},
"WebhookProvisionResponse": {
Expand All @@ -2772,7 +2777,8 @@ const openAPISpec = `{
"expires_at": { "type": "string", "format": "date-time" },
"note": { "type": "string" },
"upgrade_jwt": { "type": "string", "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions." },
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." }
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." },
"claim_url": { "type": "string", "format": "uri", "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt." }
}
},
"StorageProvisionResponse": {
Expand Down Expand Up @@ -2802,7 +2808,8 @@ const openAPISpec = `{
"note": { "type": "string", "description": "Anonymous-tier upgrade hint emitted on the 201 happy path (T19 P1-5, BugHunt 2026-05-20). Was previously undocumented; the schema only listed credentials_note which only appears on the dedup path." },
"credentials_note": { "type": "string", "description": "Present only on the rate-limited anonymous dedup response, where access_key_id/secret_access_key are NOT re-emitted (the secret is minted once at provision time and never stored)." },
"upgrade_jwt": { "type": "string", "description": "Anonymous-tier only. Signed JWT the agent can POST to /claim with an email. Absent on authenticated provisions." },
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." }
"upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t=<upgrade_jwt> URL for the dashboard claim flow." },
"claim_url": { "type": "string", "format": "uri", "description": "Anonymous-tier only. DOG-21: same URL as 'upgrade', but distinct field signals the email-claim flow to the calling agent (vs upgrade-to-paid). Agents can render this as a claim CTA directly without constructing the URL from upgrade_jwt." }
}
},
"DeployItem": {
Expand Down Expand Up @@ -3193,7 +3200,7 @@ const openAPISpec = `{
"retry_after_seconds": { "type": ["integer", "null"], "description": "Seconds the agent should wait before retrying. null on 4xx (no retry — fix the request). int on transient 5xx: 30 for 503, 60 for 429, 10 for 502/504. For 429/502/503/504 the same value is also set in the Retry-After HTTP header." },
"agent_action": { "type": "string", "description": "Optional. A sentence the calling agent should surface verbatim to the human user — e.g. 'Tell the user they've hit the hobby tier storage limit (500MB). Have them upgrade at https://instanode.dev/pricing to provision more storage.' Present on quota walls, invalid-token errors, permission-denied errors, expired-resource errors, tier-gate errors, AND on plumbing 5xx (where it falls back to a generic 'email support with this request_id' sentence)." },
"upgrade_url": { "type": "string", "format": "uri", "description": "Optional. Where the user can resolve the error — typically the pricing/upgrade page for quota walls and the login page for token errors. Present whenever following the URL would clear the error." },
"claim_url": { "type": "string", "format": "uri", "description": "Optional. Present specifically on error='free_tier_recycle_requires_claim' (402 from /db/new, /cache/new, /nosql/new, /queue/new, /storage/new, /webhook/new): the URL the anonymous caller should visit to claim their existing resources with email before they can provision again. Distinct from upgrade_url — claim_url is about identity (anonymous → claimed), upgrade_url is about tier (claimed → paid). Both may be present on the same envelope." }
"claim_url": { "type": "string", "format": "uri", "description": "Optional. Present on error='free_tier_recycle_requires_claim' (402 from /db/new, /cache/new, /nosql/new, /queue/new, /storage/new, /webhook/new, /vector/new): the URL the anonymous caller should visit to claim their existing resources with email before they can provision again. DOG-21 (QA 2026-05-29): ALSO emitted on every successful 201 anonymous provision response under each service's response schema — agents can surface a claim CTA on first provision instead of waiting for the recycle gate. Distinct from upgrade_url — claim_url is about identity (anonymous → claimed), upgrade_url is about tier (claimed → paid). Both may be present on the same envelope." }
},
"required": ["ok", "error", "message", "retry_after_seconds"]
},
Expand Down
2 changes: 2 additions & 0 deletions internal/handlers/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ func (h *QueueHandler) NewQueue(c *fiber.Ctx) error {
"note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
"claim_url": upgradeURL, // DOG-21: see db.go
}
setInternalURL(dedupResp, existing.Tier, connectionURL, "queue")
return respondOK(c, dedupResp)
Expand Down Expand Up @@ -382,6 +383,7 @@ func (h *QueueHandler) NewQueue(c *fiber.Ctx) error {
"note": upgradeNote(upgradeURL),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
"claim_url": upgradeURL, // DOG-21: see dedup branch above
}
// MR-P0-5: when isolated creds are minted, surface them. Tenant clients
// pass nats_jwt + nats_nkey to nats.UserJWTAndSeed(), or write the
Expand Down
2 changes: 2 additions & 0 deletions internal/handlers/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ func (h *StorageHandler) NewStorage(c *fiber.Ctx) error {
"note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
"claim_url": upgradeURL, // DOG-21: see db.go
"expires_at": existing.ExpiresAt.Time.Format(time.RFC3339),
}
// P2-05: the S3 prefix is recoverable from the persisted
Expand Down Expand Up @@ -389,6 +390,7 @@ func (h *StorageHandler) NewStorage(c *fiber.Ctx) error {
resp["note"] = upgradeNote(upgradeURL)
resp["upgrade"] = upgradeURL
resp["upgrade_jwt"] = jwtToken
resp["claim_url"] = upgradeURL // DOG-21: see dedup branch above
resp["expires_at"] = expiresAt.Format(time.RFC3339)
resp["limits"] = h.storageAnonymousLimits()
return respondCreated(c, resp)
Expand Down
2 changes: 2 additions & 0 deletions internal/handlers/vector.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ func (h *VectorHandler) NewVector(c *fiber.Ctx) error {
"note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
"claim_url": upgradeURL, // DOG-21: see db.go
}
setInternalURL(dedupResp, existing.Tier, connectionURL, "postgres")
return respondOK(c, dedupResp)
Expand Down Expand Up @@ -428,6 +429,7 @@ func (h *VectorHandler) NewVector(c *fiber.Ctx) error {
"note": upgradeNote(upgradeURL),
"upgrade": upgradeURL,
"upgrade_jwt": jwtToken,
"claim_url": upgradeURL, // DOG-21: see dedup branch above
}
// T19 P0-2 (BugHunt 2026-05-20): emit top-level expires_at for
// shape parity with storage/webhook responses; see db.go for rationale.
Expand Down
Loading
Loading