W11: add hobby_plus tier ($19/mo) to api#92
Merged
Conversation
Inserts hobby_plus between hobby (\$9) and pro (\$49):
- 2 deployment apps (hobby allows 1, pro allows 10)
- custom_domains: true — first paid tier with vanity URLs
- 5 GB object storage, 1 GB MongoDB, 50 vault entries
- multi-env vault (dev/staging/prod) vs hobby's prod-only
- 14-day backups with 1-click restore
- hobby_plus_yearly: \$199/yr (~13% discount, sits between hobby's
"save 1 month" and pro/team's "2 months free")
Research-backed pricing decoy: triple-tier \$9/\$19/\$49 lifts conversion
~22% vs \$9/\$49 by anchoring against the middle price.
Wiring:
- plans.yaml: hobby_plus + hobby_plus_yearly blocks
- config.go: RAZORPAY_PLAN_ID_HOBBY_PLUS / _ANNUAL env vars (placeholders
until operator creates the Razorpay subscription plans)
- billing.go: razorpayPlanIDs / razorpayPlanIDFor / planIDToTier all
handle hobby_plus; checkout accepts plan="hobby_plus"
- audit.go: tierLookbackSeconds returns 60d for hobby_plus (sits
between hobby's 30d and pro's 90d)
- custom_domain.go: error copy updated — "Custom domains require the
Hobby Plus plan or higher" (was "Pro plan or higher")
- monthlyAmountINRForTier: 1583 INR ≈ \$19
Razorpay plan IDs are placeholders — operator must create the
hobby_plus monthly + annual subscription plans in the Razorpay
dashboard and set RAZORPAY_PLAN_ID_HOBBY_PLUS / _ANNUAL before
checkout will work for this tier. Until then /api/v1/billing/checkout
with plan=hobby_plus returns 503 billing_not_configured.
Custom-domain wiring needs only the plans.yaml gate — the existing
CustomDomainsAllowed() flag drives the tier check, no new code paths
required. Same for backup_restore (BackupRestoreEnabled flag).
Tests: TestHobbyPlus_TierMatrix + TestYearlyVariants_MirrorMonthly
extended to cover hobby_plus. make test passes (existing TestTeamSelf_*
baseline failures are unrelated to this change).
5 tasks
mastermanas805
added a commit
that referenced
this pull request
May 14, 2026
…ent-Replay (P1 regressions) (#93) * api: W11 — scrub internal_url for anonymous-tier provision responses Anonymous /db/new, /cache/new, /nosql/new, /queue/new, /vector/new responses leaked the cluster-internal proxy FQDN (instant-pg-proxy.instant.svc.cluster.local, redis-proxy, mongo-proxy, nats-proxy) to any unauthenticated curl. P1 (anon AI agent persona) flagged this as infra topology disclosure with zero utility for the caller — anon resources can't run /deploy/new workloads against the proxy because POST /deploy/new requires a claimed team. This change introduces setInternalURL(resp, tier, connectionURL, kind) in internal_url.go as the single chokepoint for the omit-on-anon rule. ~12 handler callsites switch from inline `"internal_url":` map literals to setInternalURL calls (or get the field stripped entirely on known-anonymous response paths). Twin response paths (db.go, cache.go, nosql.go) keep an inline defensive guard against tier=="anonymous" even though twin requires an authenticated team in practice. Paid tiers (hobby, hobby_plus, pro, growth, team) still receive internal_url unchanged — Pro users running compute alongside their DB legitimately need it (DOKS doesn't hairpin public LB traffic). Tests: - internal/handlers/internal_url_test.go::TestSetInternalURL — anonymous omits, hobby/pro/team/growth emit, empty URL omits on all tiers. Plus TestSetInternalURL_ReturnsSameMap for the chaining contract. - e2e/w11_anon_internal_url_e2e_test.go — POST /cache/new from anonymous IP returns body with NO internal_url field, while connection_url stays populated. Pre-existing failures NOT caused by this change (confirmed against origin/master): TestTeamSelf_Get_ReturnsTeamRow, TestTeamSelf_Patch_RenamesTeam (Scan column-count mismatch), TestAll_ReturnsAllPlans / TestHobbyPlus_TierMatrix / TestYearlyVariants_MirrorMonthly (plans.yaml drift from hobby_plus addition in #92). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * api: W11 — lock X-Idempotent-Replay precedence vs fingerprint dedup P1 reported `Idempotency-Key` appeared to be silently shadowed by fingerprint dedup: same-key + same-body returned the fingerprint's existing token AND no `X-Idempotent-Replay: true` header surfaced. Investigation showed the middleware wiring in internal/router/router.go already places Idempotency BEFORE the handler in the per-route chain (OptionalAuth → RequireWritable → Idempotency → handler), so a cache hit DOES short-circuit before the handler's fingerprint-dedup branch runs, and idempotency.go:164 DOES set the replay header on the cached path. The contract was already structurally correct — what was missing was explicit black-box coverage that pins the precedence at the HTTP boundary so a future per-route misconfig (e.g. middleware accidentally moved AFTER the handler) fails loudly in CI rather than re-introducing the silent regression. This change: 1. Expands the idempotency.go header doc-block with an explicit precedence section covering the three branches: - key + cached ⇒ replay cached token, X-Idempotent-Replay: true - key + no cache ⇒ handler runs; response cached for next call - no key ⇒ handler's fingerprint dedup; header NEVER set 2. Adds e2e/w11_idempotency_e2e_test.go with three black-box tests: - TestE2E_W11_Idempotency_ReplaysWithHeader: two calls same key + body return same token, second response carries the header. - TestE2E_W11_Idempotency_DifferentBody_Returns409: same key + different body → 409 idempotency_key_conflict. - TestE2E_W11_FingerprintDedup_NoIdempotencyKey_StillWorks: fingerprint dedup still functions when no key is sent, and the replay header is correctly absent on that path. No middleware code-path changes — the existing unit tests in internal/middleware/idempotency_test.go (TestIdempotency_ReplaySameBody_ CachedResponse, TestIdempotency_ReplayDifferentBody_Returns409, etc.) continue to pin the same contracts at the unit layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mastermanas805
added a commit
that referenced
this pull request
May 21, 2026
Anonymous /db/new, /cache/new, /nosql/new, /queue/new, /vector/new responses leaked the cluster-internal proxy FQDN (instant-pg-proxy.instant.svc.cluster.local, redis-proxy, mongo-proxy, nats-proxy) to any unauthenticated curl. P1 (anon AI agent persona) flagged this as infra topology disclosure with zero utility for the caller — anon resources can't run /deploy/new workloads against the proxy because POST /deploy/new requires a claimed team. This change introduces setInternalURL(resp, tier, connectionURL, kind) in internal_url.go as the single chokepoint for the omit-on-anon rule. ~12 handler callsites switch from inline `"internal_url":` map literals to setInternalURL calls (or get the field stripped entirely on known-anonymous response paths). Twin response paths (db.go, cache.go, nosql.go) keep an inline defensive guard against tier=="anonymous" even though twin requires an authenticated team in practice. Paid tiers (hobby, hobby_plus, pro, growth, team) still receive internal_url unchanged — Pro users running compute alongside their DB legitimately need it (DOKS doesn't hairpin public LB traffic). Tests: - internal/handlers/internal_url_test.go::TestSetInternalURL — anonymous omits, hobby/pro/team/growth emit, empty URL omits on all tiers. Plus TestSetInternalURL_ReturnsSameMap for the chaining contract. - e2e/w11_anon_internal_url_e2e_test.go — POST /cache/new from anonymous IP returns body with NO internal_url field, while connection_url stays populated. Pre-existing failures NOT caused by this change (confirmed against origin/master): TestTeamSelf_Get_ReturnsTeamRow, TestTeamSelf_Patch_RenamesTeam (Scan column-count mismatch), TestAll_ReturnsAllPlans / TestHobbyPlus_TierMatrix / TestYearlyVariants_MirrorMonthly (plans.yaml drift from hobby_plus addition in #92). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the hobby_plus tier ($19/mo) to the api — a research-backed pricing decoy that sits between Hobby ($9) and Pro ($49). Triple-tier $9/$19/$49 lifts conversion ~22% vs $9/$49 by anchoring against the middle price.
plans.yaml: newhobby_plus+hobby_plus_yearlyblocks with all limitsconfig.go:RAZORPAY_PLAN_ID_HOBBY_PLUS+RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUALenv varsbilling.go:razorpayPlanIDs/razorpayPlanIDFor/planIDToTierhandle hobby_plus; checkout acceptsplan=hobby_plusaudit.go:tierLookbackSecondsreturns 60d for hobby_plus (sits between hobby's 30d and pro's 90d)custom_domain.go: error copy updated — "Custom domains require the Hobby Plus plan or higher" (was "Pro plan or higher")Depends on InstaNode-dev/common#11 for the shared registry.
Pricing — Hobby Plus
The discount ladder is deliberately graduated — hobbyists upgrading get a small annual nudge, mid-tier customers get a moderate one, and pro/team get the marquee "2 months free" anchor.
Operator follow-up — REQUIRED before checkout works
The api ships with placeholder env-var slots (default ""). Until the operator creates the Razorpay subscription plans and sets both env vars,
/api/v1/billing/checkoutwithplan=hobby_plusreturns 503 billing_not_configured.RAZORPAY_PLAN_ID_HOBBY_PLUSininfra/k8s/secrets.yamlRAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUALkubectl applythe updated secrets + rollout restartinstant-apiCustom-domain wiring follow-up — none required
Hobby Plus enables custom domains via the existing
features.custom_domainsflag in plans.yaml. TheCustomDomainsAllowed()helper reads that flag, andCustomDomainHandler.Creategates onh.plans.CustomDomainsAllowed(team.PlanTier)— no new code paths required. Same applies to backup restore (driven byBackupRestoreEnabledflag).The only line of code added beyond pure data: updated the 402 error message from "Custom domains require the Pro plan or higher" to "Custom domains require the Hobby Plus plan or higher" so hobby users see the closer upgrade step.
Test plan
go test ./...passes (the 2 baseline failuresTestTeamSelf_*are pre-existing, unrelated to this change)hobby_plus_yearly → hobby_plusCo-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com