From ad3a430da515a4e399f414fb8ba29ea2f9f6e970 Mon Sep 17 00:00:00 2001 From: Nathan Broadbent Date: Wed, 28 Oct 2026 11:23:12 +1300 Subject: [PATCH 1/3] fix(cli): show why the gateway refused an exec, attached run or log stream The Convox SDK drops the response to a refused WebSocket handshake, so any refusal of exec, an attached run or logs printed only "websocket: bad handshake". The CLI's websocket dialers now wrap each connection so it records the gateway's response to a refused handshake (and stops at an accepted one's headers, so nothing from the stream is kept). Execute turns websocket.ErrBadHandshake into the status and the gateway's reason, for example: the gateway refused the connection (429 Too Many Requests): too many MFA attempts, try again in a few minutes Closes #196 --- .../content/docs/concepts/audit-logging.mdx | 2 +- internal/cli/AGENTS.md | 8 +- internal/cli/convox_process_approval.go | 3 +- internal/cli/convox_process_command.go | 5 +- internal/cli/convox_process_command_test.go | 3 +- internal/cli/convox_processes_test.go | 3 +- internal/cli/root.go | 2 +- internal/cli/sdk_tls.go | 9 +- internal/cli/websocket_refusal.go | 137 ++++++++++++++ internal/cli/websocket_refusal_test.go | 169 ++++++++++++++++++ 10 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 internal/cli/websocket_refusal.go create mode 100644 internal/cli/websocket_refusal_test.go diff --git a/docs/src/content/docs/concepts/audit-logging.mdx b/docs/src/content/docs/concepts/audit-logging.mdx index 937cbcf3..30764289 100644 --- a/docs/src/content/docs/concepts/audit-logging.mdx +++ b/docs/src/content/docs/concepts/audit-logging.mdx @@ -322,7 +322,7 @@ An exec's or a process start's command (`convox run`, `convox exec`) is recorded with the credentials a command line commonly carries redacted. The gateway refuses a command longer than 50 KB as the audit log records it (redacted, then JSON-encoded) with `400` before it reaches the rack, so nothing runs that the log can't show in full, and the refusal is audited as a denial. The rack-gateway CLI checks the length before it -starts a process, so it can say why; the Convox SDK only shows `websocket: bad handshake` for a refused exec. +asks for MFA or starts a process. A command's justification (`--justification`, or the deploy approval's message for a command run under one) is recorded with it as `justification` in the details. The gateway can require one for every command, in every app diff --git a/internal/cli/AGENTS.md b/internal/cli/AGENTS.md index a488f783..ccdb4013 100644 --- a/internal/cli/AGENTS.md +++ b/internal/cli/AGENTS.md @@ -282,8 +282,10 @@ characters as `\uXXXX`, which `encoding/json` leaves raw, so `-o json` stays val - Permission errors: Clear message about which role is required - Token expiry: Auto-detect and prompt for re-login - Exec and log streams: the Convox SDK (`stdsdk.Client.Websocket`) drops the response to a refused WebSocket - handshake, so any gateway refusal of `exec`, `run` (attached) or `logs` (401, 403, 429, 400) prints only - `websocket: bad handshake`. The refusal and its reason are in the gateway's audit log and request log. The CLI - checks what it can before it connects: `run` and `exec` refuse a command too long for the audit log + handshake and returns only `websocket.ErrBadHandshake`. The CLI's websocket dialers (`secureConvoxSDK`) wrap each + connection in `handshakeRecordingConn`, which keeps the gateway's response to a refused handshake (and stops + recording at an accepted one's headers), and `Execute` turns the error into the status and the gateway's reason + (`explainRefusedHandshake`), for example `the gateway refused the connection (403 Forbidden): ...`. The CLI + still checks what it can before it connects: `run` and `exec` refuse a command too long for the audit log (`audit.CheckCommandLength`), or without a justification the app requires, before any MFA prompt or process start. diff --git a/internal/cli/convox_process_approval.go b/internal/cli/convox_process_approval.go index 7efadd1d..e444962a 100644 --- a/internal/cli/convox_process_approval.go +++ b/internal/cli/convox_process_approval.go @@ -25,8 +25,7 @@ func addJustificationFlag(cmd *cobra.Command) { } // commandJustification returns --justification, checked, or "" without one. A command without one in an app that -// requires one (required) is refused here, before anything starts: the gateway refuses it too, but the CLI can't -// show why the gateway refused an exec's WebSocket handshake. +// requires one (required) is refused here, before any MFA prompt or process start: the gateway refuses it too. func commandJustification(cmd *cobra.Command, app string, required bool) (string, error) { text, err := cmd.Flags().GetString(justificationFlag) if err != nil { diff --git a/internal/cli/convox_process_command.go b/internal/cli/convox_process_command.go index 9fc8937b..5094c87a 100644 --- a/internal/cli/convox_process_command.go +++ b/internal/cli/convox_process_command.go @@ -103,9 +103,8 @@ func runProcessCommand( } // prepareProcessCommand checks an exec or run command and reviews its script. The gateway refuses a command too -// long for its audit log, or without a justification in an app that requires one, but the Convox SDK drops the -// answer to a refused WebSocket handshake, so an exec it refused would only show "websocket: bad handshake". -// Checking the command here explains the refusal, before any MFA prompt or process start. +// long for its audit log, or without a justification in an app that requires one; checking the command here +// refuses it before any MFA prompt or process start. func prepareProcessCommand(cobraCmd *cobra.Command, args []string, action string) (processCommand, error) { if err := resolveAppFlag(cobraCmd); err != nil { return processCommand{}, err diff --git a/internal/cli/convox_process_command_test.go b/internal/cli/convox_process_command_test.go index 23363620..1f1b7ae6 100644 --- a/internal/cli/convox_process_command_test.go +++ b/internal/cli/convox_process_command_test.go @@ -52,8 +52,7 @@ func processCobraCommand(t *testing.T, cmd *cobra.Command, flags map[string]stri } // Without --justification, a command in an app that requires one (its own setting, or the gateway-wide one) is -// refused before anything is sent to the rack (the gateway's refusal of an exec would only show as -// "websocket: bad handshake"). +// refused before anything is sent to the rack. func TestProcessCommandRefusedWithoutAJustificationTheAppRequires(t *testing.T) { const required = `{"require_command_justification":{"value":true}}` for name, set := range map[string]func(*envChangeGateway){ diff --git a/internal/cli/convox_processes_test.go b/internal/cli/convox_processes_test.go index c82735af..64950312 100644 --- a/internal/cli/convox_processes_test.go +++ b/internal/cli/convox_processes_test.go @@ -100,8 +100,7 @@ func assertFlag(t *testing.T, flag *stdcli.Flag, name string, value interface{}) } } -// A command too long for the gateway's audit log is refused with the reason before any MFA prompt or process start: -// the gateway's own refusal of an exec only shows as "websocket: bad handshake". +// A command too long for the gateway's audit log is refused with the reason before any MFA prompt or process start. func TestProcessCommandsRefuseCommandsTooLongToAudit(t *testing.T) { long := strings.Repeat("x", audit.MaxRecordedCommandBytes) for _, cmd := range []*cobra.Command{ExecCommand(), RunCommand()} { diff --git a/internal/cli/root.go b/internal/cli/root.go index f891f9d6..09a28c7a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -88,5 +88,5 @@ Rack management: InstancesCommand(), ) - return rootCmd.Execute() + return explainRefusedHandshake(rootCmd.Execute()) } diff --git a/internal/cli/sdk_tls.go b/internal/cli/sdk_tls.go index dc751324..431d5db5 100644 --- a/internal/cli/sdk_tls.go +++ b/internal/cli/sdk_tls.go @@ -30,9 +30,12 @@ var sdkRootCAs *x509.CertPool // Gateway connections ignore HTTP(S)_PROXY / ALL_PROXY, as stdsdk's own HTTP client always did. gorilla // would otherwise use the TLS hook to dial the proxy itself (and skip TLS to the gateway), which breaks // logs, exec and run behind a proxy. +// +// Both websocket dialers record the gateway's answer to a refused handshake (see explainRefusedHandshake). func secureConvoxSDK() { stdsdk.DefaultClient = newVerifyingSDKClient() websocket.DefaultDialer.NetDialTLSContext = dialVerifiedTLS + websocket.DefaultDialer.NetDialContext = dialRecordingHandshake websocket.DefaultDialer.Proxy = nil } @@ -64,7 +67,11 @@ func dialVerifiedTLS(ctx context.Context, network, addr string) (net.Conn, error NetDialer: &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 10 * time.Second}, Config: verifyingTLSConfig(host), } - return dialer.DialContext(ctx, network, addr) + conn, err := dialer.DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + return recordHandshake(conn), nil } // parseGatewayURL parses a gateway base URL and refuses ones the CLI must not send credentials to: plain HTTP diff --git a/internal/cli/websocket_refusal.go b/internal/cli/websocket_refusal.go new file mode 100644 index 00000000..b31b5d88 --- /dev/null +++ b/internal/cli/websocket_refusal.go @@ -0,0 +1,137 @@ +package cli + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "sync" + + "github.com/gorilla/websocket" +) + +// maxRecordedRefusalBytes caps how much of the gateway's answer to a refused WebSocket handshake the CLI keeps. +// gorilla reads 1 KB of a refused handshake's body, so a recording rarely gets that far. +const maxRecordedRefusalBytes = 4 << 10 + +// The Convox SDK (stdsdk.Client.Websocket) drops the response gorilla returns with websocket.ErrBadHandshake, so +// a refused exec, attached run or log stream would only say "websocket: bad handshake". The CLI's WebSocket +// connections record what the gateway sends until the handshake is over, and keep the response to a refused one +// here for explainRefusedHandshake. +var lastRefusedHandshake struct { + mu sync.Mutex + response []byte +} + +func setRefusedHandshake(response []byte) { + lastRefusedHandshake.mu.Lock() + defer lastRefusedHandshake.mu.Unlock() + lastRefusedHandshake.response = append([]byte(nil), response...) +} + +func takeRefusedHandshake() []byte { + lastRefusedHandshake.mu.Lock() + defer lastRefusedHandshake.mu.Unlock() + response := lastRefusedHandshake.response + lastRefusedHandshake.response = nil + return response +} + +// handshakeRecordingConn records what the server sends until the end of its handshake response. It stops at the +// headers of an accepted handshake (101 Switching Protocols), so none of the stream itself is kept, and keeps a +// refused one's response, body included, up to maxRecordedRefusalBytes. +type handshakeRecordingConn struct { + net.Conn + recording bool + received []byte +} + +func recordHandshake(conn net.Conn) net.Conn { + return &handshakeRecordingConn{Conn: conn, recording: true} +} + +func (c *handshakeRecordingConn) Read(p []byte) (int, error) { + n, err := c.Conn.Read(p) + if c.recording && n > 0 { + c.record(p[:n]) + } + return n, err +} + +func (c *handshakeRecordingConn) record(data []byte) { + if room := maxRecordedRefusalBytes - len(c.received); len(data) > room { + data = data[:room] + } + c.received = append(c.received, data...) + full := len(c.received) >= maxRecordedRefusalBytes + if !full && !bytes.Contains(c.received, []byte("\r\n\r\n")) { + return + } + if isSwitchingProtocols(c.received) { + c.recording, c.received = false, nil + return + } + setRefusedHandshake(c.received) + if full { + c.recording, c.received = false, nil + } +} + +func isSwitchingProtocols(response []byte) bool { + return bytes.HasPrefix(response, []byte("HTTP/1.1 101 ")) || bytes.HasPrefix(response, []byte("HTTP/1.0 101 ")) +} + +// dialRecordingHandshake is the dialer for plain WebSocket connections (ws://, to a gateway on this machine). +func dialRecordingHandshake(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := (&net.Dialer{}).DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + return recordHandshake(conn), nil +} + +// handshakeRefusedError is a WebSocket handshake the gateway refused, with its reason. +type handshakeRefusedError struct { + status int + reason string +} + +func (e *handshakeRefusedError) Error() string { + msg := fmt.Sprintf("the gateway refused the connection (%d %s)", e.status, http.StatusText(e.status)) + if e.reason == "" { + return msg + } + return msg + ": " + e.reason +} + +func (*handshakeRefusedError) Unwrap() error { + return websocket.ErrBadHandshake +} + +// explainRefusedHandshake replaces websocket.ErrBadHandshake with the gateway's status and reason, when the +// connection recorded its response. +func explainRefusedHandshake(err error) error { + if !errors.Is(err, websocket.ErrBadHandshake) { + return err + } + response := takeRefusedHandshake() + if response == nil { + return err + } + resp, parseErr := http.ReadResponse(bufio.NewReader(bytes.NewReader(response)), nil) + if parseErr != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + // A body cut off at maxRecordedRefusalBytes still has its start. + body, _ := io.ReadAll(resp.Body) + refusal := &handshakeRefusedError{status: resp.StatusCode} + if len(bytes.TrimSpace(body)) > 0 { + refusal.reason = RenderGatewayError(body) + } + return refusal +} diff --git a/internal/cli/websocket_refusal_test.go b/internal/cli/websocket_refusal_test.go new file mode 100644 index 00000000..89cbb530 --- /dev/null +++ b/internal/cli/websocket_refusal_test.go @@ -0,0 +1,169 @@ +package cli + +import ( + "crypto/x509" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/convox/stdsdk" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" +) + +// refusingGateway answers every WebSocket handshake with status and body, as the gateway refuses an exec, an attached +// run or a log stream. +func refusingGateway(status int, contentType, body string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", contentType) + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + }) +} + +// dialThroughSDK opens a WebSocket the way the Convox SDK does for exec and logs, and returns the error the CLI +// would print. +func dialThroughSDK(t *testing.T, gatewayURL string) error { + t.Helper() + secureConvoxSDK() + client, err := stdsdk.New(gatewayURL) + require.NoError(t, err) + stream, err := client.Websocket("/apps/myapp/processes/web-1/exec", stdsdk.RequestOptions{}) + if err == nil { + _ = stream.Close() + } + return explainRefusedHandshake(err) +} + +func TestRefusedHandshakeShowsTheGatewaysReason(t *testing.T) { + tests := []struct { + name string + status int + contentType string + body string + want string + }{ + { + name: "plain-text refusal", + status: http.StatusForbidden, + contentType: "text/plain; charset=utf-8", + body: "permission denied: your role can't run commands in myapp\n", + want: "the gateway refused the connection (403 Forbidden): " + + "permission denied: your role can't run commands in myapp", + }, + { + name: "JSON error", + status: http.StatusTooManyRequests, + contentType: "application/json", + body: `{"error":"too many MFA attempts, try again in a few minutes"}`, + want: "the gateway refused the connection (429 Too Many Requests): " + + "too many MFA attempts, try again in a few minutes", + }, + { + name: "no body", + status: http.StatusUnauthorized, + contentType: "text/plain", + want: "the gateway refused the connection (401 Unauthorized)", + }, + { + name: "control characters in the reason", + status: http.StatusBadRequest, + contentType: "text/plain", + body: "bad \x1b[2Jrequest", + want: "the gateway refused the connection (400 Bad Request): bad ?[2Jrequest", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(refusingGateway(tt.status, tt.contentType, tt.body)) + defer srv.Close() + + err := dialThroughSDK(t, srv.URL) + require.EqualError(t, err, tt.want) + require.ErrorIs(t, err, websocket.ErrBadHandshake) + require.Nil(t, takeRefusedHandshake(), "the response is used once") + }) + } +} + +func TestRefusedHandshakeOverTLSShowsTheGatewaysReason(t *testing.T) { + srv := httptest.NewTLSServer(refusingGateway(http.StatusForbidden, "text/plain", "MFA required\n")) + defer srv.Close() + pool := x509.NewCertPool() + pool.AddCert(srv.Certificate()) + sdkRootCAs = pool + t.Cleanup(func() { sdkRootCAs = nil }) + + require.EqualError(t, dialThroughSDK(t, srv.URL), + "the gateway refused the connection (403 Forbidden): MFA required") +} + +// An accepted handshake keeps nothing: neither its headers nor anything the stream carries. +func TestAcceptedHandshakeRecordsNothing(t *testing.T) { + upgrader := websocket.Upgrader{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + _ = conn.WriteMessage(websocket.TextMessage, []byte("HTTP/1.1 403 Forbidden\r\n\r\nprocess output")) + _ = conn.Close() + })) + defer srv.Close() + secureConvoxSDK() + + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil) + require.NoError(t, err) + _, message, err := conn.ReadMessage() + require.NoError(t, err) + require.Contains(t, string(message), "process output") + require.NoError(t, conn.Close()) + require.Nil(t, takeRefusedHandshake()) +} + +// The response can arrive in pieces, and a large body is cut off at maxRecordedRefusalBytes. +func TestHandshakeRecordingConnAcrossReadsAndAtTheLimit(t *testing.T) { + read := func(response string) { + server, client := net.Pipe() + go func() { + for len(response) > 0 { + n := min(7, len(response)) + _, _ = server.Write([]byte(response[:n])) + response = response[n:] + } + _ = server.Close() + }() + conn := recordHandshake(client) + _, _ = io.Copy(io.Discard, conn) + _ = conn.Close() + } + + read("HTTP/1.1 403 Forbidden\r\nContent-Length: 13\r\n\r\nnot your app\n") + require.EqualError(t, explainRefusedHandshake(websocket.ErrBadHandshake), + "the gateway refused the connection (403 Forbidden): not your app") + + long := strings.Repeat("x", 2*maxRecordedRefusalBytes) + read("HTTP/1.1 400 Bad Request\r\nContent-Length: 8192\r\n\r\n" + long) + err := explainRefusedHandshake(websocket.ErrBadHandshake) + require.ErrorContains(t, err, "the gateway refused the connection (400 Bad Request): xxx") + require.Less(t, len(err.Error()), maxRecordedRefusalBytes+64) + + read("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\nHTTP/1.1 403 Forbidden\r\n\r\nstream") + require.Nil(t, takeRefusedHandshake()) +} + +func TestExplainRefusedHandshakeLeavesOtherErrorsAlone(t *testing.T) { + other := errors.New("connection refused") + require.Equal(t, other, explainRefusedHandshake(other)) + require.NoError(t, explainRefusedHandshake(nil)) + require.Nil(t, takeRefusedHandshake()) + require.Equal(t, websocket.ErrBadHandshake, explainRefusedHandshake(websocket.ErrBadHandshake), + "without a recorded response, the SDK's error stands") + + setRefusedHandshake([]byte("not an HTTP response")) + require.Equal(t, websocket.ErrBadHandshake, explainRefusedHandshake(websocket.ErrBadHandshake)) +} From ed2a5ca3a9200a9ca0d911efd1ec4733a7e1ae2e Mon Sep 17 00:00:00 2001 From: Nathan Broadbent Date: Wed, 28 Oct 2026 11:27:55 +1300 Subject: [PATCH 2/3] test(cli-e2e): a viewer's refused exec shows the gateway's reason Moves the deployer and viewer stages into roles.sh: stages.sh was at the 500-line limit. --- scripts/cli-e2e.sh | 2 ++ scripts/lib/cli-e2e/roles.sh | 49 +++++++++++++++++++++++++++++++++++ scripts/lib/cli-e2e/stages.sh | 40 ---------------------------- 3 files changed, 51 insertions(+), 40 deletions(-) create mode 100644 scripts/lib/cli-e2e/roles.sh diff --git a/scripts/cli-e2e.sh b/scripts/cli-e2e.sh index 8cfc64df..76ad883d 100755 --- a/scripts/cli-e2e.sh +++ b/scripts/cli-e2e.sh @@ -23,6 +23,8 @@ source "${ROOT_DIR}/scripts/lib/cli-e2e/cli_helpers.sh" # shellcheck disable=SC1091 source "${ROOT_DIR}/scripts/lib/cli-e2e/stages.sh" # shellcheck disable=SC1091 +source "${ROOT_DIR}/scripts/lib/cli-e2e/roles.sh" +# shellcheck disable=SC1091 source "${ROOT_DIR}/scripts/lib/cli-e2e/suite.sh" trap 'reset_all_test_state || true' EXIT diff --git a/scripts/lib/cli-e2e/roles.sh b/scripts/lib/cli-e2e/roles.sh new file mode 100644 index 00000000..7d35a348 --- /dev/null +++ b/scripts/lib/cli-e2e/roles.sh @@ -0,0 +1,49 @@ +# shellcheck shell=bash +# CLI E2E stages for the deployer and viewer roles: what each can do, and the refusals it gets. + +run_deployer_tests() { + if [[ -n "$SKIP_DEPLOYER_TESTS" ]]; then + return + fi + + login_cli_as "deployer@example.com" "e2e" + + verify_rgw_command "ps" "p-web-1" "p-worker-1" + verify_rgw_command "env" "DATABASE_URL=********************" "NODE_ENV=production" "PORT=3000" + verify_rgw_command_failure "env get DATABASE_URL --unmask" \ + "Error: failed to fetch env: You don't have permission to view secrets." + + clear_mfa_replay_protection + local delete_code + delete_code=$(generate_totp_code "$(get_totp_secret 'deployer@example.com')") + verify_rgw_command_failure "apps delete rack-gateway --mfa-code $delete_code" "Error: permission denied" + + logout_cli +} + +run_viewer_tests() { + if [[ -n "$SKIP_VIEWER_TESTS" ]]; then + return + fi + + login_cli_as "viewer@example.com" "e2e" + + verify_rgw_command "ps" "p-web-1" "p-worker-1" + verify_rgw_command_failure "env" \ + "Error: failed to fetch env: insufficient permissions: requires convox:env:read" + verify_rgw_command_failure "env get DATABASE_URL --unmask" \ + "Error: failed to fetch env: insufficient permissions: requires convox:env:read" + + # The gateway refuses a viewer's exec at the WebSocket handshake, and the CLI shows its reason. + clear_mfa_replay_protection + local exec_code + exec_code=$(generate_totp_code "$(get_totp_secret 'viewer@example.com')") + verify_rgw_command_failure "exec p-web-1 --mfa-code $exec_code 'echo hi'" \ + "Error: the gateway refused the connection (403 Forbidden): You don't have permission to run processes." + + clear_mfa_replay_protection + local delete_code + delete_code=$(generate_totp_code "$(get_totp_secret 'viewer@example.com')") + verify_rgw_command_failure "env set NOTALLOWED=1" "Error: permission denied" + verify_rgw_command_failure "apps delete rack-gateway --mfa-code $delete_code" "Error: permission denied" +} diff --git a/scripts/lib/cli-e2e/stages.sh b/scripts/lib/cli-e2e/stages.sh index 46e21b27..dc6e3f56 100644 --- a/scripts/lib/cli-e2e/stages.sh +++ b/scripts/lib/cli-e2e/stages.sh @@ -457,43 +457,3 @@ EOF verify_rgw_command "api-token delete $API_TOKEN_PUBLIC_ID --mfa-code $delete_code" "Deleted token $API_TOKEN_PUBLIC_ID" logout_cli } - -run_deployer_tests() { - if [[ -n "$SKIP_DEPLOYER_TESTS" ]]; then - return - fi - - login_cli_as "deployer@example.com" "e2e" - - verify_rgw_command "ps" "p-web-1" "p-worker-1" - verify_rgw_command "env" "DATABASE_URL=********************" "NODE_ENV=production" "PORT=3000" - verify_rgw_command_failure "env get DATABASE_URL --unmask" \ - "Error: failed to fetch env: You don't have permission to view secrets." - - clear_mfa_replay_protection - local delete_code - delete_code=$(generate_totp_code "$(get_totp_secret 'deployer@example.com')") - verify_rgw_command_failure "apps delete rack-gateway --mfa-code $delete_code" "Error: permission denied" - - logout_cli -} - -run_viewer_tests() { - if [[ -n "$SKIP_VIEWER_TESTS" ]]; then - return - fi - - login_cli_as "viewer@example.com" "e2e" - - verify_rgw_command "ps" "p-web-1" "p-worker-1" - verify_rgw_command_failure "env" \ - "Error: failed to fetch env: insufficient permissions: requires convox:env:read" - verify_rgw_command_failure "env get DATABASE_URL --unmask" \ - "Error: failed to fetch env: insufficient permissions: requires convox:env:read" - - clear_mfa_replay_protection - local delete_code - delete_code=$(generate_totp_code "$(get_totp_secret 'viewer@example.com')") - verify_rgw_command_failure "env set NOTALLOWED=1" "Error: permission denied" - verify_rgw_command_failure "apps delete rack-gateway --mfa-code $delete_code" "Error: permission denied" -} From 090781d9e59c1d2ccb09c7330b6de601488feac4 Mon Sep 17 00:00:00 2001 From: Nathan Broadbent Date: Wed, 28 Oct 2026 11:58:21 +1300 Subject: [PATCH 3/3] chore: bump version to v0.1.29 --- convox.yml | 4 ++-- web/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/convox.yml b/convox.yml index df7892ed..0b4ac3a3 100644 --- a/convox.yml +++ b/convox.yml @@ -30,7 +30,7 @@ services: # Pinned to an immutable release tag. scripts/bump-version.sh sets it to the new # version (:vX.Y.Z); the release workflow publishes that tag when the v* git tag is pushed. # :e327add is the v0.1.1 release (identical to :latest when this was pinned). - image: docker.io/docspringcom/rack-gateway:v0.1.28 + image: docker.io/docspringcom/rack-gateway:v0.1.29 command: ./scripts/start-gateway.sh environment: - PORT=8080 @@ -54,7 +54,7 @@ services: admin: # Uses same image as gateway service (kept in sync by scripts/bump-version.sh) - image: docker.io/docspringcom/rack-gateway:v0.1.28 + image: docker.io/docspringcom/rack-gateway:v0.1.29 command: echo "Use this service to run migrations, database admin tasks, etc." environment: - PORT=8080 diff --git a/web/package.json b/web/package.json index cda66484..1630d77a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.1.28", + "version": "0.1.29", "private": true, "type": "module", "scripts": {