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
12 changes: 12 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,20 @@ type Config struct {
// dashboard and set this env var before checkout will work.
RazorpayPlanIDHobbyPlus string // RAZORPAY_PLAN_ID_HOBBY_PLUS — plan_id for hobby_plus tier (monthly)
RazorpayPlanIDPro string // RAZORPAY_PLAN_ID_PRO — plan_id for pro tier (monthly)
// RazorpayPlanIDGrowth — plan_id for the W12 growth tier ($99/mo,
// monthly). When unset, /api/v1/billing/checkout with plan="growth"
// returns 503 billing_not_configured; the reconciler also logs
// `billing.plan_id_to_tier.unrecognised` for any incoming Growth
// webhook so the operator notices the gap. D28 F3 (2026-05-21).
RazorpayPlanIDGrowth string // RAZORPAY_PLAN_ID_GROWTH — plan_id for growth tier (monthly)
RazorpayPlanIDTeam string // RAZORPAY_PLAN_ID_TEAM — plan_id for team tier (monthly)
// Yearly billing variants. When unset, the corresponding yearly checkout
// returns 503 billing_not_configured so partial rollout (monthly already
// live, yearly plans not yet created in Razorpay dashboard) is safe.
RazorpayPlanIDHobbyYearly string // RAZORPAY_PLAN_ID_HOBBY_YEARLY — plan_id for hobby tier (yearly)
RazorpayPlanIDHobbyPlusYearly string // RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL — plan_id for hobby_plus tier (yearly)
RazorpayPlanIDProYearly string // RAZORPAY_PLAN_ID_PRO_YEARLY — plan_id for pro tier (yearly)
RazorpayPlanIDGrowthYearly string // RAZORPAY_PLAN_ID_GROWTH_ANNUAL — plan_id for growth tier (yearly)
RazorpayPlanIDTeamYearly string // RAZORPAY_PLAN_ID_TEAM_YEARLY — plan_id for team tier (yearly)
ResendAPIKey string
// EmailProvider explicitly selects the outbound email backend. Accepted
Expand Down Expand Up @@ -262,6 +269,10 @@ func Load() *Config {
RazorpayPlanIDHobby: os.Getenv("RAZORPAY_PLAN_ID_HOBBY"),
RazorpayPlanIDHobbyPlus: os.Getenv("RAZORPAY_PLAN_ID_HOBBY_PLUS"),
RazorpayPlanIDPro: os.Getenv("RAZORPAY_PLAN_ID_PRO"),
// D28 F3 (2026-05-21): Growth tier — was previously missing from
// the env-mapping, causing every subscription.charged webhook for
// a Growth customer to fall back to "hobby" and silently downgrade.
RazorpayPlanIDGrowth: os.Getenv("RAZORPAY_PLAN_ID_GROWTH"),
RazorpayPlanIDTeam: os.Getenv("RAZORPAY_PLAN_ID_TEAM"),
// 2026-05-15: the live instant-secrets uses the `_ANNUAL` suffix
// for every yearly plan id. config.go previously read `_YEARLY`
Expand All @@ -275,6 +286,7 @@ func Load() *Config {
RazorpayPlanIDHobbyYearly: os.Getenv("RAZORPAY_PLAN_ID_HOBBY_ANNUAL"),
RazorpayPlanIDHobbyPlusYearly: os.Getenv("RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL"),
RazorpayPlanIDProYearly: os.Getenv("RAZORPAY_PLAN_ID_PRO_ANNUAL"),
RazorpayPlanIDGrowthYearly: os.Getenv("RAZORPAY_PLAN_ID_GROWTH_ANNUAL"),
RazorpayPlanIDTeamYearly: os.Getenv("RAZORPAY_PLAN_ID_TEAM_ANNUAL"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailProvider: os.Getenv("EMAIL_PROVIDER"),
Expand Down
21 changes: 21 additions & 0 deletions internal/handlers/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ func (h *BillingHandler) razorpayPlanIDs() map[string]string {
if h.cfg.RazorpayPlanIDPro != "" {
m["pro"] = h.cfg.RazorpayPlanIDPro
}
if h.cfg.RazorpayPlanIDGrowth != "" {
m["growth"] = h.cfg.RazorpayPlanIDGrowth
}
if h.cfg.RazorpayPlanIDTeam != "" {
m["team"] = h.cfg.RazorpayPlanIDTeam
}
Expand Down Expand Up @@ -350,6 +353,14 @@ func (h *BillingHandler) razorpayPlanIDFor(tier, frequency string) string {
return h.cfg.RazorpayPlanIDProYearly
}
return h.cfg.RazorpayPlanIDPro
case "growth":
// D28 F3 (2026-05-21): Growth tier ($99/mo). Returns "" until
// the operator creates the RAZORPAY_PLAN_ID_GROWTH /
// _GROWTH_ANNUAL plans in the Razorpay dashboard.
if frequency == "yearly" {
return h.cfg.RazorpayPlanIDGrowthYearly
}
return h.cfg.RazorpayPlanIDGrowth
case "team":
if frequency == "yearly" {
return h.cfg.RazorpayPlanIDTeamYearly
Expand Down Expand Up @@ -399,6 +410,16 @@ func (h *BillingHandler) planIDToTier(planID string) string {
if h.cfg.RazorpayPlanIDTeamYearly != "" && planID == h.cfg.RazorpayPlanIDTeamYearly {
return "team"
}
// D28 F3 (2026-05-21): Growth tier added. Tier checks are ordered from
// most-paid to least-paid so a misconfigured shared plan_id resolves
// to the higher tier (least-bad outcome — customer paid more, gets
// more) rather than silently downgrading.
if h.cfg.RazorpayPlanIDGrowth != "" && planID == h.cfg.RazorpayPlanIDGrowth {
return "growth"
}
if h.cfg.RazorpayPlanIDGrowthYearly != "" && planID == h.cfg.RazorpayPlanIDGrowthYearly {
return "growth"
}
if h.cfg.RazorpayPlanIDPro != "" && planID == h.cfg.RazorpayPlanIDPro {
return "pro"
}
Expand Down
9 changes: 9 additions & 0 deletions internal/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,15 @@ var codeToAgentAction = map[string]errorCodeMeta{
"invalid_operation": {
AgentAction: "Tell the user the operation value is invalid. Use GET or PUT for /storage/:token/presign — see https://instanode.dev/docs/storage.",
},
"path_unsafe": {
AgentAction: "Tell the user the object path contains unsafe characters. Use a clean UTF-8 path with no '..', leading slash, or empty segments — see https://instanode.dev/docs/storage.",
},
"cross_team_session": {
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.",
},
"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.",
},
Expand Down
77 changes: 64 additions & 13 deletions internal/handlers/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -1366,9 +1366,33 @@ func (h *StackHandler) Redeploy(c *fiber.Ctx) error {
// #11: a no-env resource must never silently read production secrets.
vaultEnv = models.EnvDefault
}
// A08 F1 + B14 F1 (2026-05-21): merge stacks.env_vars (set via PATCH
// /stacks/:slug/env) over the manifest. Without this load, every key
// set via PATCH is silently dropped on the next redeploy — migration
// 062 persists env_vars correctly but the redeploy path never reads
// it. Manifest wins on key collision so an in-manifest override of
// a PATCH'd key (e.g. agent fixed a typo in the manifest itself)
// takes precedence over the older PATCH value.
persistedEnv, envErr := models.GetStackEnvVars(c.Context(), h.db, stack.ID)
if envErr != nil {
slog.Error("stack.redeploy.env_vars_load_failed",
"error", envErr, "slug", slug, "stack_id", stack.ID,
"request_id", middleware.GetRequestID(c))
return respondError(c, fiber.StatusServiceUnavailable, "env_load_failed",
"Failed to load stack env_vars")
}

services := make([]compute.StackServiceDef, 0, len(m.Services))
for svcName, svc := range m.Services {
envVars := svc.Env
// Merge: start with the PATCH'd env_vars, then layer the manifest
// values on top so manifest wins on collision.
envVars := make(map[string]string, len(persistedEnv)+len(svc.Env))
for k, v := range persistedEnv {
envVars[k] = v
}
for k, v := range svc.Env {
envVars[k] = v
}
resolved, vaultErr := ResolveVaultRefs(c.Context(), h.db, h.cfg.AESKey, team.ID, vaultEnv, envVars)
if vaultErr != nil {
slog.Error("stack.redeploy.vault_resolve_failed",
Expand Down Expand Up @@ -2170,23 +2194,50 @@ func (h *StackHandler) Promote(c *fiber.Ctx) error {
vaultEnv = to
}

// A08 F1 + B14 F1 (2026-05-21): load env_vars from BOTH source and
// target stack so PATCH /stacks/:slug/env contributions survive a
// promote. Without this, a key set on a staging stack is lost when
// promoted to prod. We prefer target's PATCH'd env over source's so
// per-env overrides on the target take precedence; the source's env
// then layers below. (The manifest is not re-evaluated here — promote
// rolls out the cached image — but the env_vars contract still applies
// at runtime through the StackServiceDef.EnvVars field.)
sourcePatchEnv, srcEnvErr := models.GetStackEnvVars(c.Context(), h.db, source.ID)
if srcEnvErr != nil {
slog.Error("stack.promote.source_env_vars_load_failed",
"error", srcEnvErr, "slug", slug, "stack_id", source.ID,
"request_id", middleware.GetRequestID(c))
return respondError(c, fiber.StatusServiceUnavailable, "env_load_failed",
"Failed to load source env_vars")
}
targetPatchEnv, tgtEnvErr := models.GetStackEnvVars(c.Context(), h.db, target.ID)
if tgtEnvErr != nil {
slog.Error("stack.promote.target_env_vars_load_failed",
"error", tgtEnvErr, "slug", target.Slug, "stack_id", target.ID,
"request_id", middleware.GetRequestID(c))
return respondError(c, fiber.StatusServiceUnavailable, "env_load_failed",
"Failed to load target env_vars")
}

services := make([]compute.StackServiceDef, 0, len(sourceSvcs))
for _, src := range sourceSvcs {
// Vault refs on the source's manifest were resolved at /stacks/new
// time, so the source service rows don't store the raw `vault://`
// strings — only the resolved values. To re-resolve against the
// target env we'd need to keep the original manifest around. Until
// /stacks/new persists the manifest, the promote path skips re-
// resolution and trusts what's on the deployed image. The target's
// env is still set correctly on the stack row, so future redeploys
// (with a tarball) WILL resolve against the right vault namespace.
// strings — only the resolved values. The target's env is set
// correctly on the stack row, so future redeploys (with a tarball)
// WILL resolve against the right vault namespace.
//
// We DO still pass through the vaultEnv into a no-op ResolveVaultRefs
// call so any future inline vault refs (e.g. env vars set via
// PATCH /stacks/:slug/env on the target) get resolved against the
// target's namespace and not the source's. Today envVars is empty,
// so this is a placeholder for the env_overrides workstream.
envVars := map[string]string{}
// envVars now carries both source and target PATCH'd env_vars
// (target wins on collision). ResolveVaultRefs runs against the
// target's vault namespace so vault://KEY references resolve from
// the env we're promoting INTO, not the env we're promoting FROM.
envVars := make(map[string]string, len(sourcePatchEnv)+len(targetPatchEnv))
for k, v := range sourcePatchEnv {
envVars[k] = v
}
for k, v := range targetPatchEnv {
envVars[k] = v
}
resolved, vaultErr := ResolveVaultRefs(c.Context(), h.db, h.cfg.AESKey, team.ID, vaultEnv, envVars)
if vaultErr != nil {
slog.Error("stack.promote.vault_resolve_failed",
Expand Down
Loading
Loading