From 887ce7c5b5eee24efde4fb587e3984a7376ff47c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 23:39:18 +0530 Subject: [PATCH] feat(api): populate claim_url on every 201 anon provision response (DOG-21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOG-21 — marketing landing page L106-109 promises "the agent surfaces the claim card directly in chat" with a clickable claim_url, but on the live 201 response /db/new (and every sibling) returned `claim_url: null`. Agents had to hand-construct `https://api.instanode.dev/start?t=` from the upgrade_jwt field — pushing composition responsibility into every vendor integration and producing different broken links per integration. Fix: emit `claim_url` alongside `upgrade` / `upgrade_jwt` at every 201 anonymous provision site. Same URL semantically (the /start?t= page handles both claim and upgrade flows); distinct field signals intent so an agent can render a "Claim with email" CTA without parsing the upgrade URL from upgrade_jwt. Surfaces covered (rule 22): - db.go dedup branch + fresh-201 (2 sites) - cache.go dedup branch + fresh-201 (2 sites) - nosql.go dedup branch + fresh-201 (2 sites) - queue.go dedup branch + fresh-201 (2 sites) - vector.go dedup branch + fresh-201 (2 sites) - webhook.go dedup branch + fresh-201 (2 sites) - storage.go dedup branch + fresh-201 (2 sites) Total: 14 anon-provision emit sites — all carry claim_url. - openapi.go all 7 response schemas + ErrorResponse description widened to cover the 201-anon case (the pre-fix doc had it only on 402 recycle-gate, which is the documentation gap that produced the original report). What did NOT change: - upgrade / upgrade_jwt fields unchanged — back-compat preserved - Same URL as upgrade — no new endpoint, no new minting - Authenticated paths unchanged (no anon claim flow there) Coverage block: Symptom: claim_url:null on 201 /db/new despite marketing promise Enumeration: rg -n '"upgrade":[[:space:]]+upgradeURL,' internal/handlers/ Sites found: 14 (7 dedup + 7 fresh-201) Sites touched: 14 / 14 Coverage test: TestDOG21_ClaimURLEmittedOnEveryAnonProvision iterates the live anonProvisionHandlerFiles registry (rule 18 — registry- iterating, not hand-typed) and asserts claim_url count >= upgrade_jwt count per file; TestDOG21_OpenAPISchemaDocumentsClaimURL asserts ErrorResponse.claim_url description reflects the new 201 emit (rule 22 surface checklist). Live verified: pre-merge: see DOG-21 in personal-dogfood-log.md ("claim_url: null in the response"). post-merge curl below. Local gate: - go build ./... PASS - go vet ./... PASS - go test -short -run TestOpenAPI ./internal/handlers/ PASS (all 18 pass) - go test -short -run TestDOG21 ./internal/handlers/ PASS - go test -short -run Recycle ./internal/handlers/ PASS Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/handlers/cache.go | 2 + internal/handlers/db.go | 7 ++ internal/handlers/dog21_claim_url_test.go | 90 +++++++++++++++++++++++ internal/handlers/nosql.go | 2 + internal/handlers/openapi.go | 23 ++++-- internal/handlers/queue.go | 2 + internal/handlers/storage.go | 2 + internal/handlers/vector.go | 2 + internal/handlers/webhook.go | 2 + 9 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 internal/handlers/dog21_claim_url_test.go diff --git a/internal/handlers/cache.go b/internal/handlers/cache.go index bc2dd29..0ec2a3f 100644 --- a/internal/handlers/cache.go +++ b/internal/handlers/cache.go @@ -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 != "" { @@ -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. diff --git a/internal/handlers/db.go b/internal/handlers/db.go index c0ef724..1e81442 100644 --- a/internal/handlers/db.go +++ b/internal/handlers/db.go @@ -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= 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) @@ -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 diff --git a/internal/handlers/dog21_claim_url_test.go b/internal/handlers/dog21_claim_url_test.go new file mode 100644 index 0000000..2c7739c --- /dev/null +++ b/internal/handlers/dog21_claim_url_test.go @@ -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)") +} diff --git a/internal/handlers/nosql.go b/internal/handlers/nosql.go index 887dfa2..5bee61f 100644 --- a/internal/handlers/nosql.go +++ b/internal/handlers/nosql.go @@ -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) @@ -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. diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index 204e567..bf802af 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -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= 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= 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": { @@ -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= URL for the dashboard claim flow." } + "upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t= 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": { @@ -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= URL for the dashboard claim flow." } + "upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t= 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": { @@ -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= URL for the dashboard claim flow." } + "upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t= 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": { @@ -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= URL for the dashboard claim flow." } + "upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t= 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": { @@ -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= URL for the dashboard claim flow." } + "upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t= 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": { @@ -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= URL for the dashboard claim flow." } + "upgrade": { "type": "string", "format": "uri", "description": "Anonymous-tier only. Pre-baked GET /start?t= 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": { @@ -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"] }, diff --git a/internal/handlers/queue.go b/internal/handlers/queue.go index b1d617b..932ef5b 100644 --- a/internal/handlers/queue.go +++ b/internal/handlers/queue.go @@ -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) @@ -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 diff --git a/internal/handlers/storage.go b/internal/handlers/storage.go index bbfdf45..bc1a7c7 100644 --- a/internal/handlers/storage.go +++ b/internal/handlers/storage.go @@ -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 @@ -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) diff --git a/internal/handlers/vector.go b/internal/handlers/vector.go index a3d25fc..277f605 100644 --- a/internal/handlers/vector.go +++ b/internal/handlers/vector.go @@ -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) @@ -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. diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index 5cf5aee..4b06113 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -289,6 +289,7 @@ func (h *WebhookHandler) NewWebhook(c *fiber.Ctx) error { "note": limitExceededNote(upgradeURL, existing.ExpiresAt.Time), "upgrade": upgradeURL, "upgrade_jwt": jwtToken, + "claim_url": upgradeURL, // DOG-21: see db.go } if existing.ExpiresAt.Valid { // P2-03: emit RFC3339 (not the default RFC3339Nano of a raw @@ -390,6 +391,7 @@ func (h *WebhookHandler) NewWebhook(c *fiber.Ctx) error { "note": upgradeNote(upgradeURL), "upgrade": upgradeURL, "upgrade_jwt": jwtToken, + "claim_url": upgradeURL, // DOG-21: see dedup branch above // P2-03: RFC3339 to match storage.go and the webhook dedup branch — // one wire shape for expires_at across all provisioning endpoints. "expires_at": expiresAt.Format(time.RFC3339),