Skip to content

Commit 116a17b

Browse files
authored
feat(client): send Vercel protection-bypass header when VERCEL_AUTOMATION_BYPASS_SECRET is set (#21)
Pipeboard PR previews sit behind Vercel SSO and answer HTTP 401 'Protected deployment' to the CLI, so PIPEBOARD_API_URL could never point at unmerged code. When VERCEL_AUTOMATION_BYPASS_SECRET is set, every request (MCP call/notify, REST, and the unauthenticated tools-hash fetch) now carries it as x-vercel-protection-bypass. Hosts without protection ignore the header. Covered by httptest-based tests for all four request kinds, present and absent.
1 parent 02ac550 commit 116a17b

6 files changed

Lines changed: 173 additions & 8 deletions

File tree

‎CLAUDE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ Token resolution order:
4343
1. `PIPEBOARD_API_TOKEN` env var
4444
2. `~/.pipeboard/config.json` token field
4545

46+
`VERCEL_AUTOMATION_BYPASS_SECRET` (optional) is sent as `x-vercel-protection-bypass`
47+
on every request so `PIPEBOARD_API_URL` can target SSO-protected Vercel preview
48+
deployments. See `internal/client/headers.go`.
49+
4650
### Build-time variables
4751

4852
Version and commit are injected via ldflags (see Makefile and .goreleaser.yml):

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,24 @@ Get your API token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens)
5151
pipeboard login
5252
```
5353

54+
### Targeting a Vercel preview deployment
55+
56+
`PIPEBOARD_API_URL` can point at any Pipeboard deployment, including a pull
57+
request's Vercel preview. Previews are protected by Vercel SSO, so set the
58+
project's [Protection Bypass for Automation](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation)
59+
secret and the CLI sends it as `x-vercel-protection-bypass` on every request:
60+
61+
```bash
62+
export VERCEL_AUTOMATION_BYPASS_SECRET=... # from the Vercel project settings
63+
export PIPEBOARD_API_URL=https://<deployment>.vercel.app
64+
pipeboard mcp --server meta-ads-mcp tools-list
65+
```
66+
67+
Hosts without deployment protection ignore the header, so it is safe to leave
68+
set. Without it, a protected preview answers
69+
`HTTP 401 {"error":{"message":"Protected deployment"}}` before the app sees the
70+
request.
71+
5472
## Quick Start
5573

5674
```bash

‎internal/client/headers.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package client
2+
3+
import (
4+
"net/http"
5+
"os"
6+
)
7+
8+
// BypassSecretEnv is the environment variable holding a Vercel "Protection
9+
// Bypass for Automation" secret. Pipeboard preview deployments
10+
// (PIPEBOARD_API_URL=https://<deployment>.vercel.app) sit behind Vercel SSO
11+
// and answer HTTP 401 "Protected deployment" to any request that does not
12+
// present the secret. When the variable is set, every request carries it as
13+
// the x-vercel-protection-bypass header; hosts without deployment protection
14+
// ignore the header, so leaving it set is harmless.
15+
const BypassSecretEnv = "VERCEL_AUTOMATION_BYPASS_SECRET"
16+
17+
// bypassHeader is the header Vercel's edge reads the secret from.
18+
const bypassHeader = "x-vercel-protection-bypass"
19+
20+
// setCommonHeaders applies the headers every Pipeboard request needs: bearer
21+
// auth (skipped when token is empty, e.g. the unauthenticated tools-hash
22+
// endpoint), User-Agent, and the Vercel protection bypass when configured.
23+
func setCommonHeaders(req *http.Request, token, userAgent string) {
24+
if token != "" {
25+
req.Header.Set("Authorization", "Bearer "+token)
26+
}
27+
req.Header.Set("User-Agent", userAgent)
28+
if secret := os.Getenv(BypassSecretEnv); secret != "" {
29+
req.Header.Set(bypassHeader, secret)
30+
}
31+
}

‎internal/client/headers_test.go‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package client
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
)
9+
10+
// recordingServer answers every request with a minimal valid body and keeps
11+
// the headers of the most recent request for assertions.
12+
type recordingServer struct {
13+
*httptest.Server
14+
last http.Header
15+
}
16+
17+
func newRecordingServer(t *testing.T) *recordingServer {
18+
t.Helper()
19+
rs := &recordingServer{}
20+
rs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+
rs.last = r.Header.Clone()
22+
w.Header().Set("Content-Type", "application/json")
23+
if r.Method == http.MethodGet {
24+
_, _ = w.Write([]byte(`{"hash":"abc"}`))
25+
return
26+
}
27+
var req struct {
28+
ID int64 `json:"id"`
29+
}
30+
_ = json.NewDecoder(r.Body).Decode(&req)
31+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
32+
"jsonrpc": "2.0",
33+
"id": req.ID,
34+
"result": map[string]interface{}{"tools": []interface{}{}},
35+
})
36+
}))
37+
t.Cleanup(rs.Close)
38+
return rs
39+
}
40+
41+
func (rs *recordingServer) bypass() (string, bool) {
42+
v, ok := rs.last[http.CanonicalHeaderKey(bypassHeader)]
43+
if !ok || len(v) == 0 {
44+
return "", false
45+
}
46+
return v[0], true
47+
}
48+
49+
func TestBypassHeader_SentOnEveryRequestKindWhenConfigured(t *testing.T) {
50+
t.Setenv(BypassSecretEnv, "s3cret")
51+
srv := newRecordingServer(t)
52+
53+
c := New(srv.URL, "tok", "test")
54+
if _, err := c.ListTools("meta-ads-mcp"); err != nil {
55+
t.Fatalf("ListTools: %v", err)
56+
}
57+
if got, ok := srv.bypass(); !ok || got != "s3cret" {
58+
t.Errorf("tools/list: bypass header = %q, %v; want s3cret", got, ok)
59+
}
60+
if got := srv.last.Get("Authorization"); got != "Bearer tok" {
61+
t.Errorf("tools/list: Authorization = %q; want Bearer tok", got)
62+
}
63+
if got := srv.last.Get("User-Agent"); got != "pipeboard-cli/test" {
64+
t.Errorf("tools/list: User-Agent = %q", got)
65+
}
66+
67+
if err := c.notify("meta-ads-mcp", "notifications/initialized", nil); err != nil {
68+
t.Fatalf("notify: %v", err)
69+
}
70+
if got, ok := srv.bypass(); !ok || got != "s3cret" {
71+
t.Errorf("notify: bypass header = %q, %v; want s3cret", got, ok)
72+
}
73+
74+
rest := NewREST(srv.URL, "tok", "test")
75+
if _, err := rest.Post("/api/debug/x", map[string]string{"a": "b"}); err != nil {
76+
t.Fatalf("REST Post: %v", err)
77+
}
78+
if got, ok := srv.bypass(); !ok || got != "s3cret" {
79+
t.Errorf("REST: bypass header = %q, %v; want s3cret", got, ok)
80+
}
81+
82+
if _, err := FetchToolsHash(srv.URL, "test"); err != nil {
83+
t.Fatalf("FetchToolsHash: %v", err)
84+
}
85+
if got, ok := srv.bypass(); !ok || got != "s3cret" {
86+
t.Errorf("tools-hash: bypass header = %q, %v; want s3cret", got, ok)
87+
}
88+
if got := srv.last.Get("Authorization"); got != "" {
89+
t.Errorf("tools-hash must stay unauthenticated, got Authorization=%q", got)
90+
}
91+
}
92+
93+
func TestBypassHeader_OmittedWhenUnset(t *testing.T) {
94+
t.Setenv(BypassSecretEnv, "")
95+
srv := newRecordingServer(t)
96+
97+
c := New(srv.URL, "tok", "test")
98+
if _, err := c.ListTools("meta-ads-mcp"); err != nil {
99+
t.Fatalf("ListTools: %v", err)
100+
}
101+
if got, ok := srv.bypass(); ok {
102+
t.Errorf("bypass header should be absent when env is unset, got %q", got)
103+
}
104+
if got := srv.last.Get("Authorization"); got != "Bearer tok" {
105+
t.Errorf("Authorization = %q; want Bearer tok", got)
106+
}
107+
108+
if _, err := FetchToolsHash(srv.URL, "test"); err != nil {
109+
t.Fatalf("FetchToolsHash: %v", err)
110+
}
111+
if got, ok := srv.bypass(); ok {
112+
t.Errorf("tools-hash: bypass header should be absent, got %q", got)
113+
}
114+
}

‎internal/client/mcp.go‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,7 @@ func (c *Client) call(serverPath, method string, params interface{}) (json.RawMe
178178
}
179179

180180
httpReq.Header.Set("Content-Type", "application/json")
181-
httpReq.Header.Set("Authorization", "Bearer "+c.token)
182-
httpReq.Header.Set("User-Agent", c.userAgent)
181+
setCommonHeaders(httpReq, c.token, c.userAgent)
183182

184183
resp, err := c.httpClient.Do(httpReq)
185184
if err != nil {
@@ -215,13 +214,14 @@ type ToolsHashResult struct {
215214

216215
// FetchToolsHash retrieves the combined tools hash from the server.
217216
// baseURL is the MCP base URL (e.g. https://mcp.pipeboard.co).
218-
// This endpoint requires no authentication.
217+
// This endpoint requires no authentication, but it still needs the Vercel
218+
// protection bypass when baseURL is a preview deployment.
219219
func FetchToolsHash(baseURL, version string) (*ToolsHashResult, error) {
220220
req, err := http.NewRequest("GET", baseURL+"/api/tools-hash", nil)
221221
if err != nil {
222222
return nil, fmt.Errorf("creating request: %w", err)
223223
}
224-
req.Header.Set("User-Agent", "pipeboard-cli/"+version)
224+
setCommonHeaders(req, "", "pipeboard-cli/"+version)
225225

226226
resp, err := http.DefaultClient.Do(req)
227227
if err != nil {
@@ -266,8 +266,7 @@ func (c *Client) notify(serverPath, method string, params interface{}) error {
266266
}
267267

268268
httpReq.Header.Set("Content-Type", "application/json")
269-
httpReq.Header.Set("Authorization", "Bearer "+c.token)
270-
httpReq.Header.Set("User-Agent", c.userAgent)
269+
setCommonHeaders(httpReq, c.token, c.userAgent)
271270

272271
resp, err := c.httpClient.Do(httpReq)
273272
if err != nil {

‎internal/client/rest.go‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@ func (c *RESTClient) Post(path string, body interface{}) (map[string]interface{}
4040
}
4141

4242
req.Header.Set("Content-Type", "application/json")
43-
req.Header.Set("Authorization", "Bearer "+c.token)
44-
req.Header.Set("User-Agent", c.userAgent)
43+
setCommonHeaders(req, c.token, c.userAgent)
4544

4645
resp, err := c.httpClient.Do(req)
4746
if err != nil {

0 commit comments

Comments
 (0)