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
13 changes: 7 additions & 6 deletions docs/src/content/docs/integrations/deploy-approvals/workflow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -409,14 +409,15 @@ or the app's `vcs_repo` is missing. No request or `create` entry exists for a re
<TabItem label="No Valid Approval">

```
Error: You don't have permission to promote releases.
Error: You don't have permission to promote releases: this API token has no usable deploy approval for myapp:
its newest request, 3f2b9c1e-8d4a-4b6e-9f1a-2c7d5e8a1b0f (commit 0123456), was approved, but its approval
window ended at 2026-10-13T11:59:00Z
```

A token that relies on `deploy_with_approval` gets the same 403 as any other missing permission when it has no
approved, unexpired request for the app (`permission denied` for uploads and builds). The gateway doesn't say
which, so it can't be used to probe approvals. With `LOG_LEVEL=debug` and `DEBUG_TOPICS=deploy.approval`, the
gateway logs the reason (`deploy approval expired`, `deploy approval status check failed: expected=approved
actual=pending`, or not found).
A token that relies on `deploy_with_approval` gets a 403 that says why, from its own newest request for the app:
it has none, or that request is waiting for approval, was rejected, has already been deployed, expired, or is
approved but doesn't cover this upload, build, release or command. The token can already read its own requests, so
the message reveals nothing new.

**Causes:**
- No request was created for this app and token, or it was rejected
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/integrations/email.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ Sent to the address that tried when a login fails after Google (for example an a

#### Rate Limit Exceeded

Sent when a request is rate limited: to the signed-in user (if any), and to every admin.
Sent when a request is rate limited: to the signed-in user (if any), and to every admin who isn't locked
(looked up when it's sent).

**To User:**
| Field | Content |
Expand Down
6 changes: 3 additions & 3 deletions docs/src/content/docs/security/rbac/permissions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ Resources that map to Convox API operations:
| `secret` | Secret env var values (`convox:secret:read` reveals them) |

The gateway forwards only the rack API routes it has a permission for (see
[Rack API Routes](#rack-api-routes)). Requests to any other route are refused with `404` and logged as denied, so
`certs`, `registries`, `resources`, `instances keyroll`, `instances terminate` and `apps cancel` don't work
through the gateway, whatever the caller's role.
[Rack API Routes](#rack-api-routes)). Requests to any other route are refused with `404` and logged as denied,
whatever the caller's role, so Convox's certificates, registries, resources, instance keyroll and terminate, and
canceling an app update aren't available through the gateway (the `rack-gateway` CLI has no commands for them).

### Gateway Resources

Expand Down
9 changes: 5 additions & 4 deletions docs/src/content/docs/user-guide/cli/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ rack-gateway test-auth mfa --mfa-method webauthn # Verify a specific method

### rack

Show the current rack and your login. Its subcommands (`info`, `logs`, `params`, `ps`, `releases`, `scale`,
Show the current rack (or the one `--rack` or `RACK_GATEWAY_RACK` selects) and your login. Its subcommands (`info`, `logs`, `params`, `ps`, `releases`, `scale`,
`update`) act on the rack itself.

```bash
Expand Down Expand Up @@ -131,9 +131,10 @@ rack-gateway switch staging

## Convox Commands

These commands are proxied to the Convox rack API. They work the same as the native `convox` CLI commands, for the
rack API routes the gateway forwards. `certs`, `registries`, `resources`, `instances keyroll`, `instances terminate`
and `apps cancel` are refused (see [Permissions](/security/rbac/permissions/#rack-api-routes)).
These commands are proxied to the Convox rack API and work the same as the native `convox` CLI commands. The CLI
only has commands for the rack API routes the gateway forwards (see
[Permissions](/security/rbac/permissions/#rack-api-routes)), so Convox commands such as `certs`, `registries`,
`resources`, `instances keyroll`, `instances terminate` and `apps cancel` aren't available.

Commands that take `-a, --app` find the app from the flag, then `CONVOX_APP`, then a `.convox/app` file in the
current directory or a parent, then the current directory's name.
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/user-guide/cli/multi-rack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,8 @@ rack-gateway login production

It's normal to have different users or roles on different racks. Each rack maintains its own session:

`rack-gateway rack` shows who you're logged in as on the current rack (it always shows the current rack, whatever
`--rack` says); `switch` to another rack to see its login.
`rack-gateway rack` shows who you're logged in as on the current rack; `rack-gateway rack -r staging` shows your
login on staging.

## Next Steps

Expand Down
4 changes: 1 addition & 3 deletions docs/src/content/docs/user-guide/mfa/trusted-devices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ You can trust a device in two ways:
<Steps>

1. Complete MFA verification
2. Check **Trust this device for 30 days**
2. Check **Trust this device for N days** (the configured TTL, 30 by default)
3. Finish sign-in

</Steps>
Expand All @@ -38,8 +38,6 @@ You can trust a device in two ways:

</Steps>

The checkbox and the button always say 30 days, but the trust lasts as long as the configured TTL.

## Managing Trusted Devices

The **Trusted Devices** table shows:
Expand Down
7 changes: 4 additions & 3 deletions internal/cli/cli_rack.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import (
func RackCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "rack",
Short: "Show current rack configuration",
Long: "Display the current rack name, gateway URL, and login status from local configuration (no API call)",
Args: cobra.NoArgs,
Short: "Show the selected rack's configuration",
Long: "Display the selected rack's name, gateway URL, and login status from local configuration (no API " +
"call). That's the current rack unless --rack or RACK_GATEWAY_RACK selects another.",
Args: cobra.NoArgs,
RunE: SilenceOnError(func(_ *cobra.Command, _ []string) error {
status, err := ResolveRackStatus(time.Now())
if err != nil {
Expand Down
37 changes: 37 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ func TestSetCurrentRackCreatesDirectory(t *testing.T) {
func TestResolveRackStatusPrefersConfig(t *testing.T) {
tmpDir := t.TempDir()
ConfigPath = tmpDir
t.Setenv("RACK_GATEWAY_RACK", "")
t.Setenv("RACK_GATEWAY_URL", "")
t.Setenv("RACK_GATEWAY_API_TOKEN", "")

Expand All @@ -233,6 +234,42 @@ func TestResolveRackStatusPrefersConfig(t *testing.T) {
assert.Contains(t, strings.Join(status.StatusLines, " "), "Logged in as user@example.com")
}

// `rack` shows the rack commands would use: --rack, then RACK_GATEWAY_RACK, then the current rack.
func TestResolveRackStatusFollowsTheSelectedRack(t *testing.T) {
gateway := func(host string) GatewayConfig {
return GatewayConfig{
URL: "https://" + host,
Token: "abc123",
Email: "user@example.com",
ExpiresAt: time.Now().Add(time.Hour),
}
}
withCLIConfig(t, Config{Current: "production", Gateways: map[string]GatewayConfig{
"production": gateway("gateway-production.example.com"),
"staging": gateway("gateway-staging.example.com"),
}})
t.Setenv("RACK_GATEWAY_RACK", "")

status, err := ResolveRackStatus(time.Now())
require.NoError(t, err)
assert.Equal(t, "production", status.Rack)

t.Setenv("RACK_GATEWAY_RACK", "staging")
status, err = ResolveRackStatus(time.Now())
require.NoError(t, err)
assert.Equal(t, "staging", status.Rack)
assert.Equal(t, "https://gateway-staging.example.com", status.GatewayURL)

RackFlag = "production"
status, err = ResolveRackStatus(time.Now())
require.NoError(t, err)
assert.Equal(t, "production", status.Rack)

RackFlag = "eu"
_, err = ResolveRackStatus(time.Now())
require.ErrorContains(t, err, "rack eu not configured")
}

func TestResolveRackStatusFallsBackToEnv(t *testing.T) {
ConfigPath = t.TempDir()
t.Setenv("RACK_GATEWAY_RACK", "")
Expand Down
23 changes: 0 additions & 23 deletions internal/cli/convox_apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ func AppsCommand() *cobra.Command {
cmd.AddCommand(appsCreateCommand())
cmd.AddCommand(appsDeleteCommand())
cmd.AddCommand(appsInfoCommand())
cmd.AddCommand(appsCancelCommand())
cmd.AddCommand(appsLockCommand())
cmd.AddCommand(appsUnlockCommand())
cmd.AddCommand(appsParamsCommand())
Expand Down Expand Up @@ -112,28 +111,6 @@ func appsInfoCommand() *cobra.Command {
return cmd
}

func appsCancelCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cancel <name>",
Short: "cancel an app update",
Args: cobra.ExactArgs(1),
RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error {
mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "apps cancel")
if err != nil {
return err
}

client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth)
if err != nil {
return err
}
return cli.AppsCancel(client, ctx)
}),
}

return cmd
}

func appsLockCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "lock <name>",
Expand Down
50 changes: 0 additions & 50 deletions internal/cli/convox_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,55 +56,5 @@ func InstancesCommand() *cobra.Command {
}),
}

// Add subcommands
cmd.AddCommand(instancesKeyrollCommand())
cmd.AddCommand(instancesTerminateCommand())

return cmd
}

func instancesKeyrollCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "keyroll",
Short: "roll SSH keys",
Args: cobra.NoArgs,
RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error {
mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "instances keyroll")
if err != nil {
return err
}

client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth)
if err != nil {
return err
}
return cli.InstancesKeyroll(client, ctx)
}),
}

cmd.Flags().Bool("wait", false, "wait for keyroll to complete")

return cmd
}

func instancesTerminateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "terminate <id>",
Short: "terminate an instance",
Args: cobra.ExactArgs(1),
RunE: SilenceOnError(func(cobraCmd *cobra.Command, args []string) error {
mfaAuth, err := checkMFAAndGetAuth(cobraCmd, "instances terminate")
if err != nil {
return err
}

client, ctx, err := SetupConvoxCommandWithMFA(cobraCmd, args, mfaAuth)
if err != nil {
return err
}
return cli.InstancesTerminate(client, ctx)
}),
}

return cmd
}
Loading