From 7d824dd6bf94afb20c30e66c570135ce432ecb92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 11:46:57 +0530 Subject: [PATCH] =?UTF-8?q?chore(copy):=20support@instanode.dev=20?= =?UTF-8?q?=E2=86=92=20contact@instanode.dev=20(public=20contact=20email)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing contact email changed to contact@instanode.dev everywhere it appears (error/agent_action messages, billing/checkout/plan-change copy, transactional email footer, OpenAPI descriptions + snapshot). No senders changed (sender stays noreply@instanode.dev). The /support URL is unchanged — only the email address. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/email/email.go | 14 +++---- internal/handlers/agent_action.go | 2 +- internal/handlers/billing.go | 14 +++---- .../billing_block_no_cancel_downgrade_test.go | 2 +- internal/handlers/error_envelope_test.go | 4 +- internal/handlers/helpers.go | 38 +++++++++---------- internal/handlers/openapi.go | 8 ++-- internal/middleware/admin.go | 2 +- internal/middleware/admin_audit_test.go | 2 +- openapi.snapshot.json | 8 ++-- plans.yaml | 2 +- 11 files changed, 48 insertions(+), 48 deletions(-) diff --git a/internal/email/email.go b/internal/email/email.go index 3f6cf160..3bc61f18 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -553,11 +553,11 @@ func clampAttemptCount(n int) int { // TEXT column on the table, so adding a new kind never needs a migration — // the only invariant is "operators can filter by kind in the dashboard". const ( - EmailSendKindPaymentFailed = "payment_failed" - EmailSendKindPaymentReceipt = "receipt" - EmailSendKindTeamInvite = "team_invite" - EmailSendKindDeletionConfirm = "deletion_confirm" - EmailSendKindMagicLink = "magic_link" + EmailSendKindPaymentFailed = "payment_failed" + EmailSendKindPaymentReceipt = "receipt" + EmailSendKindTeamInvite = "team_invite" + EmailSendKindDeletionConfirm = "deletion_confirm" + EmailSendKindMagicLink = "magic_link" ) // SendPaymentFailed sends a payment failure notification email. @@ -728,7 +728,7 @@ Receipt View your billing details: https://instanode.dev/app/billing -Need help? Reply to this email or contact support@instanode.dev. +Need help? Reply to this email or contact contact@instanode.dev. — The instanode.dev team `, headline, leadPlain, receipt.Plan, amountPlain, receipt.Period) @@ -752,7 +752,7 @@ Need help? Reply to this email or contact support@instanode.dev.

Need help? Reply to this email or contact - support@instanode.dev. + contact@instanode.dev.

— The instanode.dev team

diff --git a/internal/handlers/agent_action.go b/internal/handlers/agent_action.go index 6444fa0f..29e1243d 100644 --- a/internal/handlers/agent_action.go +++ b/internal/handlers/agent_action.go @@ -347,7 +347,7 @@ const AgentActionBindingLookupFailed = "Tell the user the platform couldn't reso // the middleware can't import handlers (cycle), so both sides keep their // own copy. The contract test asserts only one of the two copies; touching // either without the other is the regression we want CI to catch. -const AgentActionAdminRequired = "Tell the user this endpoint requires platform-admin access. Ask support@instanode.dev via https://instanode.dev/support if you think this is wrong." +const AgentActionAdminRequired = "Tell the user this endpoint requires platform-admin access. Ask contact@instanode.dev via https://instanode.dev/support if you think this is wrong." // newAgentActionAdminTierChanged is returned in the success response of // POST /api/v1/admin/customers/:team_id/tier so the calling agent has diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index bc64e659..471d8711 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -997,7 +997,7 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { // "contact sales / not yet available" message instead of telling // the user they made a typo. return respondError(c, fiber.StatusBadRequest, "tier_not_yet_available", - "The Team plan is not yet available for self-serve checkout — contact support@instanode.dev.") + "The Team plan is not yet available for self-serve checkout — contact contact@instanode.dev.") default: return respondError(c, fiber.StatusBadRequest, "invalid_plan", "plan must be 'hobby', 'hobby_plus', or 'pro'") } @@ -3141,7 +3141,7 @@ func resolveTeamFromNotes(ctx context.Context, h *BillingHandler, sub rzpSubscri // dashboard, executed by support staff, which fires the subscription.cancelled // webhook → handleSubscriptionCancelled in RazorpayWebhook (unchanged). // -// The dashboard surfaces cancellation as a mailto:support@instanode.dev link, +// The dashboard surfaces cancellation as a mailto:contact@instanode.dev link, // not as a button that calls this API. // // If a future internal flow (RTBF / team deletion) needs to cancel a @@ -3461,7 +3461,7 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error { // OK — fall through. case "yearly": return respondError(c, fiber.StatusBadRequest, "yearly_change_plan_unsupported", - "Changing to a yearly plan via /change-plan is not yet supported. Cancel and use POST /api/v1/billing/checkout with plan_frequency='yearly', or contact support@instanode.dev.") + "Changing to a yearly plan via /change-plan is not yet supported. Cancel and use POST /api/v1/billing/checkout with plan_frequency='yearly', or contact contact@instanode.dev.") default: return respondError(c, fiber.StatusBadRequest, "invalid_frequency", "plan_frequency must be 'monthly' or 'yearly'") @@ -3489,7 +3489,7 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error { // happens to be set in this environment. if target == "team" { return respondError(c, fiber.StatusBadRequest, "tier_not_yet_available", - "The Team plan is not yet available for self-serve plan changes — contact support@instanode.dev.") + "The Team plan is not yet available for self-serve plan changes — contact contact@instanode.dev.") } planIDs := h.razorpayPlanIDs() if _, ok := planIDs[target]; !ok { @@ -3505,9 +3505,9 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error { targetRank := plans.Rank(target) if currentRank >= 0 && targetRank >= 0 && targetRank <= currentRank { return respondErrorWithAgentAction(c, fiber.StatusBadRequest, "downgrade_not_self_serve", - "Plan downgrades are handled by support, not self-serve. Email support@instanode.dev to change to a lower tier.", - "Tell the user that downgrading to a lower plan is support-assisted. Have them email support@instanode.dev with their team and the target plan.", - "mailto:support@instanode.dev") + "Plan downgrades are handled by support, not self-serve. Email contact@instanode.dev to change to a lower tier.", + "Tell the user that downgrading to a lower plan is support-assisted. Have them email contact@instanode.dev with their team and the target plan.", + "mailto:contact@instanode.dev") } // (Target=team is rejected above with tier_not_yet_available — the // 2026-06-04 CEO re-gate. Only hobby/hobby_plus/pro upgrades reach here.) diff --git a/internal/handlers/billing_block_no_cancel_downgrade_test.go b/internal/handlers/billing_block_no_cancel_downgrade_test.go index 583a166e..182680e4 100644 --- a/internal/handlers/billing_block_no_cancel_downgrade_test.go +++ b/internal/handlers/billing_block_no_cancel_downgrade_test.go @@ -140,7 +140,7 @@ func TestBillingBlock_NoSelfServeCancelOrDowngradeRoute(t *testing.T) { // paying team requesting a LOWER or EQUAL tier via the in-app change-plan path // is rejected with downgrade_not_self_serve and routed to support, NOT // silently dropped. Verified against billing.go:ChangePlanAPI (it returns 400 -// downgrade_not_self_serve + a mailto:support@instanode.dev agent_action for +// downgrade_not_self_serve + a mailto:contact@instanode.dev agent_action for // any target whose rank ≤ the current tier's rank). func TestBillingBlock_ChangePlanRejectsDowngrade(t *testing.T) { if billingBlockSkipNoDB(t) { diff --git a/internal/handlers/error_envelope_test.go b/internal/handlers/error_envelope_test.go index 56e50d96..9b9eed57 100644 --- a/internal/handlers/error_envelope_test.go +++ b/internal/handlers/error_envelope_test.go @@ -236,7 +236,7 @@ func TestErrorEnvelope_500_NoRetryAfter(t *testing.T) { assert.NotEmpty(t, body["agent_action"], "5xx must always carry an agent_action — registry entry preferred, fallback as floor") if action, ok := body["agent_action"].(string); ok { - assert.Contains(t, action, "support@instanode.dev", + assert.Contains(t, action, "contact@instanode.dev", "every 5xx agent_action — whether registry or fallback — names the support contact") } } @@ -336,7 +336,7 @@ func TestErrorEnvelope_ContactSupportContract(t *testing.T) { assert.Contains(t, s, "request_id", "AgentActionContactSupport must name request_id so the user knows what to quote") // 3. Exact next action. - assert.Contains(t, s, "support@instanode.dev", + assert.Contains(t, s, "contact@instanode.dev", "AgentActionContactSupport must name the support email — that's the action") // 4. Full https URL. assert.Contains(t, s, "https://instanode.dev/", diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index ba905bb2..b999f17f 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -81,7 +81,7 @@ type errorCodeMeta struct { // Used by respondError when status >= 500 and the code is not in the // registry. Keeps the agent_action field populated even for plumbing // errors so the calling agent always has something concrete to relay. -const AgentActionContactSupport = "Tell the user something on our side went wrong. Email support@instanode.dev with this request_id and a brief description — see https://instanode.dev/support." +const AgentActionContactSupport = "Tell the user something on our side went wrong. Email contact@instanode.dev with this request_id and a brief description — see https://instanode.dev/support." // codeToAgentAction maps respondError `code` values to the sentence the // agent should surface and, where relevant, the upgrade URL. Codes absent @@ -152,7 +152,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ // right "not yet available / contact sales" message. Refs: memory // `project_team_plan_not_rolled_out_no_payment`. "tier_not_yet_available": { - AgentAction: "Tell the user the Team plan is not yet available for self-serve purchase. They should contact support@instanode.dev — see https://instanode.dev/pricing.", + AgentAction: "Tell the user the Team plan is not yet available for self-serve purchase. They should contact contact@instanode.dev — see https://instanode.dev/pricing.", UpgradeURL: "https://instanode.dev/pricing", }, "events_query_failed": { @@ -288,7 +288,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user this invitation has already been accepted — they're on the team. Have them open https://instanode.dev/app to see their resources.", }, "already_claimed": { - AgentAction: "Tell the user these resources were already claimed by another account. If they believe this is wrong, have them email support@instanode.dev — see https://instanode.dev/support.", + AgentAction: "Tell the user these resources were already claimed by another account. If they believe this is wrong, have them email contact@instanode.dev — see https://instanode.dev/support.", }, // ── Expired / gone ───────────────────────────────────────────────────── @@ -663,7 +663,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user their session belongs to a different team than the storage token. Re-authenticate as the token's owning team — see https://instanode.dev/docs/auth.", }, "env_load_failed": { - AgentAction: "Tell the user the persisted environment variables could not be loaded for this stack. Retry the redeploy in 30 seconds — see https://instanode.dev/status. If it keeps failing, email support@instanode.dev with the request_id.", + AgentAction: "Tell the user the persisted environment variables could not be loaded for this stack. Retry the redeploy in 30 seconds — see https://instanode.dev/status. If it keeps failing, email contact@instanode.dev with the request_id.", }, "invalid_service": { AgentAction: "Tell the user the service value is unknown. Use one of: postgres, redis, mongodb, queue, storage, webhook, vector — see https://instanode.dev/docs.", @@ -876,7 +876,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user billing is not configured on this deployment. Operators must set RAZORPAY_KEY_ID / SECRET — see https://instanode.dev/docs/billing.", }, "downgrade_not_self_serve": { - AgentAction: "Tell the user downgrades and cancellations are not self-serve. Email support@instanode.dev — see https://instanode.dev/support.", + AgentAction: "Tell the user downgrades and cancellations are not self-serve. Email contact@instanode.dev — see https://instanode.dev/support.", }, "yearly_change_plan_unsupported": { AgentAction: "Tell the user yearly subscriptions can't switch plans inline. Cancel the current subscription, then start the new plan at https://instanode.dev/pricing.", @@ -888,7 +888,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ // ── Razorpay codes (kept as raw passthrough) ─────────────────────────── "razorpay_error": { - AgentAction: "Tell the user Razorpay returned an error completing the payment. Check the error message and retry, or contact support@instanode.dev — see https://instanode.dev/support.", + AgentAction: "Tell the user Razorpay returned an error completing the payment. Check the error message and retry, or contact contact@instanode.dev — see https://instanode.dev/support.", }, // ── Validation 4xx: signature / state ────────────────────────────────── @@ -939,7 +939,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user the platform database hit a transient error. Retry in 30 seconds with exponential backoff — see https://instanode.dev/status if it persists.", }, "internal_error": { - AgentAction: "Tell the user something on our side went wrong. Email support@instanode.dev with this request_id, or check https://instanode.dev/status.", + AgentAction: "Tell the user something on our side went wrong. Email contact@instanode.dev with this request_id, or check https://instanode.dev/status.", }, "lookup_failed": { AgentAction: "Tell the user a lookup on the platform backend timed out. Retry in 30 seconds — see https://instanode.dev/status.", @@ -988,7 +988,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user creating the restore failed. Retry in 60 seconds — see https://instanode.dev/status.", }, "restore_failed": { - AgentAction: "Tell the user the restore did not complete. Retry in 60 seconds; if it persists email support@instanode.dev — see https://instanode.dev/status.", + AgentAction: "Tell the user the restore did not complete. Retry in 60 seconds; if it persists email contact@instanode.dev — see https://instanode.dev/status.", }, "deletion_request_failed": { AgentAction: "Tell the user the team-deletion request failed to persist. Retry in 30 seconds — see https://instanode.dev/status.", @@ -1000,10 +1000,10 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user recording the promote rejection failed. Retry the rejection in 30 seconds — see https://instanode.dev/status.", }, "execute_failed": { - AgentAction: "Tell the user executing the action failed. Retry in 30 seconds; if it persists email support@instanode.dev — see https://instanode.dev/support.", + AgentAction: "Tell the user executing the action failed. Retry in 30 seconds; if it persists email contact@instanode.dev — see https://instanode.dev/support.", }, "summary_failed": { - AgentAction: "Tell the user computing the summary failed. Retry in 30 seconds; if it persists email support@instanode.dev — see https://instanode.dev/support.", + AgentAction: "Tell the user computing the summary failed. Retry in 30 seconds; if it persists email contact@instanode.dev — see https://instanode.dev/support.", }, "status_failed": { AgentAction: "Tell the user reading the status failed. Retry in 30 seconds — see https://instanode.dev/status.", @@ -1012,13 +1012,13 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user reading the resource status failed. Retry in 30 seconds — see https://instanode.dev/status.", }, "tier_failed": { - AgentAction: "Tell the user updating the tier failed. Retry in 30 seconds; if it persists email support@instanode.dev — see https://instanode.dev/support.", + AgentAction: "Tell the user updating the tier failed. Retry in 30 seconds; if it persists email contact@instanode.dev — see https://instanode.dev/support.", }, "upgrade_failed": { - AgentAction: "Tell the user the tier upgrade could not be applied right now. Retry in 30 seconds; if it persists email support@instanode.dev — see https://instanode.dev/support.", + AgentAction: "Tell the user the tier upgrade could not be applied right now. Retry in 30 seconds; if it persists email contact@instanode.dev — see https://instanode.dev/support.", }, "revocation_failed": { - AgentAction: "Tell the user revoking the session failed. Retry in 30 seconds; if it persists email support@instanode.dev — see https://instanode.dev/support.", + AgentAction: "Tell the user revoking the session failed. Retry in 30 seconds; if it persists email contact@instanode.dev — see https://instanode.dev/support.", }, "role_lookup_failed": { AgentAction: "Tell the user a team-role lookup failed. Retry in 30 seconds — see https://instanode.dev/status.", @@ -1027,13 +1027,13 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user a team lookup failed. Retry in 30 seconds — see https://instanode.dev/status.", }, "team_creation_failed": { - AgentAction: "Tell the user creating the team failed. Retry in 30 seconds; if it persists email support@instanode.dev — see https://instanode.dev/support.", + AgentAction: "Tell the user creating the team failed. Retry in 30 seconds; if it persists email contact@instanode.dev — see https://instanode.dev/support.", }, "team_has_no_users": { AgentAction: "Tell the user this team has no users yet — add an owner before issuing operations against it. See https://instanode.dev/docs/team.", }, "user_creation_failed": { - AgentAction: "Tell the user creating the user account failed. Retry in 30 seconds; if it persists email support@instanode.dev — see https://instanode.dev/support.", + AgentAction: "Tell the user creating the user account failed. Retry in 30 seconds; if it persists email contact@instanode.dev — see https://instanode.dev/support.", }, "user_upsert_failed": { AgentAction: "Tell the user upserting the user record failed. Retry in 30 seconds — see https://instanode.dev/status.", @@ -1061,10 +1061,10 @@ var codeToAgentAction = map[string]errorCodeMeta{ }, // (deletion_token_invalid covered in the deletion-confirmed section above) "encryption_failed": { - AgentAction: "Tell the user the encryption step failed. Retry in 30 seconds; if it persists email support@instanode.dev with this request_id — see https://instanode.dev/support.", + AgentAction: "Tell the user the encryption step failed. Retry in 30 seconds; if it persists email contact@instanode.dev with this request_id — see https://instanode.dev/support.", }, "decrypt_failed": { - AgentAction: "Tell the user decrypting the stored credential failed. Retry in 30 seconds; if it persists email support@instanode.dev with this request_id — see https://instanode.dev/support.", + AgentAction: "Tell the user decrypting the stored credential failed. Retry in 30 seconds; if it persists email contact@instanode.dev with this request_id — see https://instanode.dev/support.", }, "encryption_unavailable": { AgentAction: "Tell the user the encryption backend is temporarily unavailable. Retry in 60 seconds — see https://instanode.dev/status.", @@ -1127,7 +1127,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user the quota check failed. Retry in 30 seconds — see https://instanode.dev/status.", }, "billing_persistence_failed": { - AgentAction: "Tell the user persisting the billing change failed. Retry the action in 30 seconds; if it persists email support@instanode.dev with this request_id — see https://instanode.dev/support.", + AgentAction: "Tell the user persisting the billing change failed. Retry the action in 30 seconds; if it persists email contact@instanode.dev with this request_id — see https://instanode.dev/support.", }, // ── 429 rate-limited (canonical) ─────────────────────────────────────── @@ -1186,7 +1186,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user marking the deletion as confirmed failed. Retry in 30 seconds — see https://instanode.dev/status.", }, "subscription_cancel_failed": { - AgentAction: "Tell the user cancelling the Razorpay subscription failed. The team-delete is paused; email support@instanode.dev so an operator can reconcile — see https://instanode.dev/support.", + AgentAction: "Tell the user cancelling the Razorpay subscription failed. The team-delete is paused; email contact@instanode.dev so an operator can reconcile — see https://instanode.dev/support.", }, // ── Auth content-type gate (AUTH-163, CSRF). Per-IP rate-limit (AUTH-097/107) diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index a1abe2b4..19dbe773 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -1333,9 +1333,9 @@ const openAPISpec = `{ "/api/v1/billing/checkout": { "post": { "summary": "Create a Razorpay subscription and return its hosted-page URL", - "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, or pro) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. The Team plan ($199, finite high-capacity limits — not unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). Capacity beyond the Team caps is Enterprise (contact sales). plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", + "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, or pro) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. The Team plan ($199, finite high-capacity limits — not unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: contact@instanode.dev). Capacity beyond the Team caps is Enterprise (contact sales). plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", "security": [{ "bearerAuth": [] }], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro"], "description": "Self-serve purchasable plans. The Team plan is NOT yet available for self-serve checkout (contact sales: support@instanode.dev) — plan=team returns 400 tier_not_yet_available." }, "plan_frequency": { "type": "string", "enum": ["monthly", "yearly"], "default": "monthly", "description": "Billing cycle. Empty = monthly. Yearly variants follow the same canonical-tier mapping on the webhook side — teams.plan_tier still stores the bare tier name." } } } } } }, + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["plan"], "properties": { "plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro"], "description": "Self-serve purchasable plans. The Team plan is NOT yet available for self-serve checkout (contact sales: contact@instanode.dev) — plan=team returns 400 tier_not_yet_available." }, "plan_frequency": { "type": "string", "enum": ["monthly", "yearly"], "default": "monthly", "description": "Billing cycle. Empty = monthly. Yearly variants follow the same canonical-tier mapping on the webhook side — teams.plan_tier still stores the bare tier name." } } } } } }, "responses": { "200": { "description": "Subscription created (or an existing live one reused) — redirect user to short_url. reused:true means the short_url belongs to a checkout the team started earlier and no new subscription was minted. traffic_env reports whether this deployment talks to the LIVE or TEST Razorpay environment (derived from the RAZORPAY_KEY_ID prefix) — agents and the SPA branch on it without ever seeing the raw key.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "short_url": { "type": "string", "format": "uri" }, "subscription_id": { "type": "string" }, "reused": { "type": "boolean", "description": "Present and true only when an existing still-payable subscription was returned instead of minting a new one." }, "traffic_env": { "type": "string", "enum": ["production", "test"], "description": "Derived from the configured RAZORPAY_KEY_ID prefix (rzp_live_* → production, rzp_test_* → test). The raw key value is NEVER exposed in any response. Use this to detect a staging deployment accidentally pointing at the live key (which is also enforced server-side via 503 billing_misconfigured)." } } } } } }, "400": { "description": "Invalid plan, invalid plan_frequency, already_on_plan (the team already holds the requested tier or higher), or tier_not_yet_available (plan=team — the Team plan is not yet self-serve purchasable; contact sales)" }, @@ -1372,7 +1372,7 @@ const openAPISpec = `{ "/api/v1/billing/change-plan": { "post": { "summary": "Switch the team's subscription to a different tier", - "description": "Hobby ↔ Hobby Plus ↔ Pro on the same Razorpay subscription (upgrades only — downgrades are support-assisted). Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. The Team plan is NOT yet available for self-serve plan changes — target_plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev).", + "description": "Hobby ↔ Hobby Plus ↔ Pro on the same Razorpay subscription (upgrades only — downgrades are support-assisted). Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. The Team plan is NOT yet available for self-serve plan changes — target_plan=team returns 400 tier_not_yet_available (contact sales: contact@instanode.dev).", "security": [{ "bearerAuth": [] }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["target_plan"], "properties": { "target_plan": { "type": "string", "enum": ["hobby", "hobby_plus", "pro"], "description": "Target tier. The Team plan is not yet self-serve purchasable (contact sales) — target_plan=team returns 400 tier_not_yet_available." } } } } } }, "responses": { @@ -3341,7 +3341,7 @@ const openAPISpec = `{ "ok": { "type": "boolean", "enum": [false], "description": "Always false on error responses" }, "error": { "type": "string", "description": "Stable machine-readable error code (e.g. 'quota_exceeded', 'invalid_token', 'forbidden', 'storage_limit_reached'). Programmatic clients should branch on this." }, "message": { "type": "string", "description": "Human-readable explanation of the error. May contain tier names, resource IDs, or other context. Not stable — use the 'error' code for programmatic decisions." }, - "request_id": { "type": "string", "description": "Echo of the X-Request-ID header for this request. Stable correlator agents can quote when emailing support@instanode.dev — saves the user from copy/pasting headers." }, + "request_id": { "type": "string", "description": "Echo of the X-Request-ID header for this request. Stable correlator agents can quote when emailing contact@instanode.dev — saves the user from copy/pasting headers." }, "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." }, diff --git a/internal/middleware/admin.go b/internal/middleware/admin.go index f711e96f..dd8ddbe9 100644 --- a/internal/middleware/admin.go +++ b/internal/middleware/admin.go @@ -44,7 +44,7 @@ const AdminEmailsEnvVar = "ADMIN_EMAILS" // Duplicated here rather than imported because middleware is depended on by // handlers, not the other way around; a cross-import would introduce a // cycle. The handlers package re-exports the same string as a constant. -const adminForbiddenAgentAction = "Tell the user this endpoint requires platform-admin access. Ask support@instanode.dev via https://instanode.dev/support if you think this is wrong." +const adminForbiddenAgentAction = "Tell the user this endpoint requires platform-admin access. Ask contact@instanode.dev via https://instanode.dev/support if you think this is wrong." // AdminEmailAllowlist returns the parsed, lowercased ADMIN_EMAILS set. Empty // when ADMIN_EMAILS is unset or blank. Exported so tests / observability diff --git a/internal/middleware/admin_audit_test.go b/internal/middleware/admin_audit_test.go index e85e4ae9..ec214207 100644 --- a/internal/middleware/admin_audit_test.go +++ b/internal/middleware/admin_audit_test.go @@ -192,7 +192,7 @@ func TestAdminAuditEmit_RateLimited_Writes403Row(t *testing.T) { "ok": false, "error": "forbidden", "message": "platform-admin access required", - "agent_action": "Tell the user this endpoint requires platform-admin access. Ask support@instanode.dev via https://instanode.dev/support if you think this is wrong.", + "agent_action": "Tell the user this endpoint requires platform-admin access. Ask contact@instanode.dev via https://instanode.dev/support if you think this is wrong.", }) }) diff --git a/openapi.snapshot.json b/openapi.snapshot.json index a3627d9e..ccbe58fa 100644 --- a/openapi.snapshot.json +++ b/openapi.snapshot.json @@ -1117,7 +1117,7 @@ "type": "boolean" }, "request_id": { - "description": "Echo of the X-Request-ID header for this request. Stable correlator agents can quote when emailing support@instanode.dev — saves the user from copy/pasting headers.", + "description": "Echo of the X-Request-ID header for this request. Stable correlator agents can quote when emailing contact@instanode.dev — saves the user from copy/pasting headers.", "type": "string" }, "retry_after_seconds": { @@ -3051,7 +3051,7 @@ }, "/api/v1/billing/change-plan": { "post": { - "description": "Hobby ↔ Hobby Plus ↔ Pro on the same Razorpay subscription (upgrades only — downgrades are support-assisted). Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. The Team plan is NOT yet available for self-serve plan changes — target_plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev).", + "description": "Hobby ↔ Hobby Plus ↔ Pro on the same Razorpay subscription (upgrades only — downgrades are support-assisted). Proration is handled by Razorpay; the new plan takes effect at the end of the current billing period. The Team plan is NOT yet available for self-serve plan changes — target_plan=team returns 400 tier_not_yet_available (contact sales: contact@instanode.dev).", "requestBody": { "content": { "application/json": { @@ -3103,14 +3103,14 @@ }, "/api/v1/billing/checkout": { "post": { - "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, or pro) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. The Team plan ($199, finite high-capacity limits — not unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: support@instanode.dev). Capacity beyond the Team caps is Enterprise (contact sales). plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", + "description": "Mints a Razorpay subscription for the requested plan (hobby, hobby_plus, or pro) tied to the authenticated team. The dashboard redirects the user to the returned short_url to complete payment; on success Razorpay fires subscription.activated AND subscription.charged to /razorpay/webhook — both trigger the same idempotent tier-elevation path so the team is upgraded as soon as the mandate is authorised, even before the first invoice is collected. The Team plan ($199, finite high-capacity limits — not unlimited) is NOT yet available for self-serve checkout — requesting plan=team returns 400 tier_not_yet_available (contact sales: contact@instanode.dev). Capacity beyond the Team caps is Enterprise (contact sales). plan_frequency selects monthly (default) vs yearly billing — yearly returns 503 billing_not_configured until the operator creates the yearly Razorpay plan and sets RAZORPAY_PLAN_ID_*_YEARLY. promotion_code: admin-issued codes are bookmarked in the subscription notes for future discount wiring (no Razorpay Offer is applied yet — codes are not consumed until a real discount is confirmed). IDEMPOTENT: the endpoint never mints a second subscription for a team that already has a live one — if the team already holds the requested tier (or higher) it returns 400 already_on_plan, and if a prior checkout's subscription is still payable at Razorpay (status created/authenticated/pending) it returns that subscription's short_url with reused:true instead of creating a new one. This prevents a confused re-click from producing two parallel subscriptions that both charge the card.", "requestBody": { "content": { "application/json": { "schema": { "properties": { "plan": { - "description": "Self-serve purchasable plans. The Team plan is NOT yet available for self-serve checkout (contact sales: support@instanode.dev) — plan=team returns 400 tier_not_yet_available.", + "description": "Self-serve purchasable plans. The Team plan is NOT yet available for self-serve checkout (contact sales: contact@instanode.dev) — plan=team returns 400 tier_not_yet_available.", "enum": [ "hobby", "hobby_plus", diff --git a/plans.yaml b/plans.yaml index 0fec1219..43fc5af7 100644 --- a/plans.yaml +++ b/plans.yaml @@ -585,7 +585,7 @@ plans: # public /pricing page, marketing homepage, or dashboard PricingGrid. # The marketing surface stays Anonymous / Hobby / Pro / Team to keep # the comparison uncluttered. Growth is reached only via assisted - # sales conversations (support@instanode.dev) — there is no in-product + # sales conversations (contact@instanode.dev) — there is no in-product # upsell card for it today. If/when growth becomes a primary outbound # funnel, surface it in instanode-web/PricingPage.tsx and the dashboard # PricingGrid in lock-step with this file.