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
96 changes: 94 additions & 2 deletions cmd/relayfile-cli/control_plane.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
Expand All @@ -18,9 +19,9 @@ import (
"time"
)

const relayfileControlPlaneAPIVersion uint32 = 1
const relayfileControlPlaneAPIVersion uint32 = 2

var relayfileControlPlaneSupportedVersions = []uint32{relayfileControlPlaneAPIVersion}
var relayfileControlPlaneSupportedVersions = []uint32{1, relayfileControlPlaneAPIVersion}

type controlPlaneErrorCode string

Expand Down Expand Up @@ -119,6 +120,23 @@ type writebackSecretData struct {
Secret string `json:"secret"`
}

type webhookSubscriptionRequest struct {
Workspace string `json:"workspace,omitempty"`
URL string `json:"url"`
PathGlobs []string `json:"pathGlobs"`
Secret string `json:"secret"`
}

type webhookSubscriptionResponse struct {
SubscriptionID string `json:"subscriptionId"`
Secret string `json:"secret,omitempty"`
}

type deleteWebhookSubscriptionRequest struct {
Workspace string `json:"workspace,omitempty"`
SubscriptionID string `json:"subscriptionId"`
}

func runControlPlane(args []string, stdout io.Writer) error {
if len(args) == 0 {
return errors.New("control-plane subcommand is required: serve")
Expand Down Expand Up @@ -210,6 +228,7 @@ func newControlPlaneHandler() http.Handler {
mux.HandleFunc("/v1/integrations/bindings", withControlPlaneVersion(handleControlPlaneListBindings))
mux.HandleFunc("/v1/integrations/unbind", withControlPlaneVersion(handleControlPlaneUnbind))
mux.HandleFunc("/v1/integrations/writeback-secret", withControlPlaneVersion(handleControlPlaneWritebackSecret))
mux.HandleFunc("/v1/integrations/webhook-subscriptions", withControlPlaneVersion(handleControlPlaneWebhookSubscription))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The unbind handler (handleControlPlaneUnbind) only removes the local relay integration binding and does not clean up the corresponding relayfile-cloud webhook subscription. This creates a lifecycle leak: after unbind, the cloud subscription remains active and can continue delivering inbound webhooks even though the local binding no longer exists. You should persist the subscription ID on the binding so that unbind can proxy a DELETE to /v1/workspaces/{workspaceID}/webhooks/{subscriptionID} before (or as part of) removing the local binding.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/relayfile-cli/control_plane.go, line 231:

<comment>The unbind handler (`handleControlPlaneUnbind`) only removes the local relay integration binding and does not clean up the corresponding relayfile-cloud webhook subscription. This creates a lifecycle leak: after unbind, the cloud subscription remains active and can continue delivering inbound webhooks even though the local binding no longer exists. You should persist the subscription ID on the binding so that unbind can proxy a DELETE to `/v1/workspaces/{workspaceID}/webhooks/{subscriptionID}` before (or as part of) removing the local binding.</comment>

<file context>
@@ -210,6 +228,7 @@ func newControlPlaneHandler() http.Handler {
 	mux.HandleFunc("/v1/integrations/bindings", withControlPlaneVersion(handleControlPlaneListBindings))
 	mux.HandleFunc("/v1/integrations/unbind", withControlPlaneVersion(handleControlPlaneUnbind))
 	mux.HandleFunc("/v1/integrations/writeback-secret", withControlPlaneVersion(handleControlPlaneWritebackSecret))
+	mux.HandleFunc("/v1/integrations/webhook-subscriptions", withControlPlaneVersion(handleControlPlaneWebhookSubscription))
 	return mux
 }
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for flagging this. I am not expanding this PR to make unbind proxy cloud subscription deletion: the local unbind path currently only owns local binding removal and does not carry the authenticated cloud workspace/session context needed to delete relayfile-cloud webhooks safely. The binding already stores subscriptionId for that follow-up lifecycle work, but tying teardown into unbind is a cross-repo behavior change tracked separately. This PR stays scoped to exposing create/delete webhook subscription proxy operations and the typed client API.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unbind cleanup point is fair in general, but it’s out of scope for this PR. This change only adds the webhook-subscription proxy operations and typed client API; the unbind teardown is a separate cross-repo behavior change.

return mux
}

Expand Down Expand Up @@ -449,6 +468,79 @@ func handleControlPlaneWritebackSecret(w http.ResponseWriter, r *http.Request) {
writeControlPlaneJSON(w, http.StatusOK, data)
}

func handleControlPlaneWebhookSubscription(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
var req webhookSubscriptionRequest
if err := decodeControlPlaneJSON(r, &req); err != nil {
writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error())
return
}
if strings.TrimSpace(req.URL) == "" || len(req.PathGlobs) == 0 || strings.TrimSpace(req.Secret) == "" {
writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, "url, pathGlobs, and secret are required")
return
}
commandClient, err := prepareWorkspaceCommandClient(strings.TrimSpace(req.Workspace), "", "", defaultJoinScopes)
if err != nil {
writeControlPlaneMappedError(w, err)
return
}
workspaceID := strings.TrimSpace(commandClient.workspaceID)
if workspaceID == "" {
writeControlPlaneMappedError(w, errors.New("could not resolve relayfile workspace id"))
return
}
var response webhookSubscriptionResponse
if err := commandClient.client.postJSON(
r.Context(),
fmt.Sprintf("/v1/workspaces/%s/webhooks", url.PathEscape(workspaceID)),
Comment on lines +494 to +496

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use r.Context() instead of context.Background() to ensure that the request context is propagated. This allows the outgoing HTTP request to be cancelled if the client disconnects or times out.

Suggested change
if err := commandClient.client.postJSON(
context.Background(),
fmt.Sprintf("/v1/workspaces/%s/webhooks", url.PathEscape(workspaceID)),
if err := commandClient.client.postJSON(
r.Context(),
fmt.Sprintf("/v1/workspaces/%s/webhooks", url.PathEscape(workspaceID)),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d422ef6 — webhook subscription creation now passes r.Context() through to the upstream relayfile-cloud request.

map[string]any{
"url": strings.TrimSpace(req.URL),
"pathGlobs": req.PathGlobs,
"secret": strings.TrimSpace(req.Secret),
},
&response,
); err != nil {
writeControlPlaneMappedError(w, err)
return
}
writeControlPlaneJSON(w, http.StatusOK, response)
case http.MethodDelete:
var req deleteWebhookSubscriptionRequest
if err := decodeControlPlaneJSON(r, &req); err != nil {
writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error())
return
}
subscriptionID := strings.TrimSpace(req.SubscriptionID)
if subscriptionID == "" {
writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, "subscriptionId is required")
return
}
commandClient, err := prepareWorkspaceCommandClient(strings.TrimSpace(req.Workspace), "", "", defaultJoinScopes)
if err != nil {
writeControlPlaneMappedError(w, err)
return
}
workspaceID := strings.TrimSpace(commandClient.workspaceID)
if workspaceID == "" {
writeControlPlaneMappedError(w, errors.New("could not resolve relayfile workspace id"))
return
}
if err := commandClient.client.deleteJSON(
r.Context(),
fmt.Sprintf("/v1/workspaces/%s/webhooks/%s", url.PathEscape(workspaceID), url.PathEscape(subscriptionID)),
Comment on lines +529 to +531

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use r.Context() instead of context.Background() to propagate the request context and support cancellation.

Suggested change
if err := commandClient.client.deleteJSON(
context.Background(),
fmt.Sprintf("/v1/workspaces/%s/webhooks/%s", url.PathEscape(workspaceID), url.PathEscape(subscriptionID)),
if err := commandClient.client.deleteJSON(
r.Context(),
fmt.Sprintf("/v1/workspaces/%s/webhooks/%s", url.PathEscape(workspaceID), url.PathEscape(subscriptionID)),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d422ef6 — webhook subscription deletion now passes r.Context() through to the upstream relayfile-cloud request.

"",
nil,
); err != nil {
writeControlPlaneMappedError(w, err)
return
}
writeControlPlaneJSON(w, http.StatusOK, map[string]bool{"ok": true})
default:
writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed")
}
}

func controlPlaneHello() helloResponse {
return helloResponse{
DaemonVersion: relayfileVersion,
Expand Down
57 changes: 55 additions & 2 deletions cmd/relayfile-cli/control_plane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@ func TestControlPlaneHelloVersionAndCLIVersion(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("hello status = %d", status)
}
if hello.DaemonVersion != "0.10.17" || hello.APIVersion != 1 {
if hello.DaemonVersion != "0.10.17" || hello.APIVersion != 2 {
t.Fatalf("unexpected hello response: %#v", hello)
}
if len(hello.SupportedAPIVersions) != 2 || hello.SupportedAPIVersions[0] != 1 || hello.SupportedAPIVersions[1] != 2 {
t.Fatalf("unexpected supported API versions: %#v", hello.SupportedAPIVersions)
}

var errResp map[string]controlPlaneError
status = controlPlaneJSONWithoutVersion(t, client, http.MethodGet, baseURL+"/v1/hello?apiVersion=2", nil, &errResp)
status = controlPlaneJSONWithoutVersion(t, client, http.MethodGet, baseURL+"/v1/hello?apiVersion=3", nil, &errResp)
if status != http.StatusUpgradeRequired {
t.Fatalf("incompatible hello status = %d, want %d", status, http.StatusUpgradeRequired)
}
Expand Down Expand Up @@ -187,6 +190,30 @@ func TestControlPlaneCloudIntegrationConformance(t *testing.T) {
t.Fatalf("unexpected writeback-secret channel: %q", r.URL.Query().Get("channel"))
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"url":"https://relay.test/writeback","secret":"sec_123"}}`))
case "/v1/workspaces/ws_123/webhooks":
if r.Method != http.MethodPost {
t.Fatalf("expected webhooks POST, got %s", r.Method)
}
var body struct {
URL string `json:"url"`
PathGlobs []string `json:"pathGlobs"`
Secret string `json:"secret"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode webhook subscription body failed: %v", err)
}
if body.URL != "https://cast.test/v1/integrations/relayfile/inbound/ws/ch" ||
len(body.PathGlobs) != 1 ||
body.PathGlobs[0] != "/github/repos/acme/widgets/issues/**" ||
body.Secret != "inbound-secret" {
t.Fatalf("unexpected webhook subscription body: %#v", body)
}
_, _ = w.Write([]byte(`{"subscriptionId":"whsub_123"}`))
case "/v1/workspaces/ws_123/webhooks/whsub_123":
if r.Method != http.MethodDelete {
t.Fatalf("expected webhooks DELETE, got %s", r.Method)
}
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
Expand Down Expand Up @@ -240,6 +267,32 @@ func TestControlPlaneCloudIntegrationConformance(t *testing.T) {
if secret.URL != "https://relay.test/writeback" || secret.Secret != "sec_123" {
t.Fatalf("unexpected writeback secret: %#v", secret)
}

var subscription webhookSubscriptionResponse
status = controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/webhook-subscriptions", webhookSubscriptionRequest{
Workspace: "demo",
URL: "https://cast.test/v1/integrations/relayfile/inbound/ws/ch",
PathGlobs: []string{"/github/repos/acme/widgets/issues/**"},
Secret: "inbound-secret",
}, &subscription)
if status != http.StatusOK {
t.Fatalf("webhook subscription status = %d", status)
}
if subscription.SubscriptionID != "whsub_123" {
t.Fatalf("unexpected webhook subscription: %#v", subscription)
}

var deleted map[string]bool
status = controlPlaneJSON(t, client, http.MethodDelete, baseURL+"/v1/integrations/webhook-subscriptions", deleteWebhookSubscriptionRequest{
Workspace: "demo",
SubscriptionID: "whsub_123",
}, &deleted)
if status != http.StatusOK {
t.Fatalf("delete webhook subscription status = %d", status)
}
if !deleted["ok"] {
t.Fatalf("unexpected delete response: %#v", deleted)
}
}

func startControlPlaneTestServer(t *testing.T) (*http.Client, string, func()) {
Expand Down
79 changes: 77 additions & 2 deletions openapi/relayfile-control-plane-v1.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ info:
Versioned local control-plane contract for the relayfile daemon/CLI.
The service listens on a user-owned Unix domain socket
(`RELAYFILE_SOCK`, or `$XDG_RUNTIME_DIR/relayfile.sock`) and uses
`X-Relayfile-API-Version: 1` for client compatibility checks.
`X-Relayfile-API-Version: 2` for client compatibility checks.
servers:
- url: http://relayfile.local
description: Logical HTTP origin over the relayfile Unix domain socket.
Expand Down Expand Up @@ -228,6 +228,43 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/WritebackSecret"
/v1/integrations/webhook-subscriptions:
post:
operationId: createWebhookSubscription
summary: Create a server-side relayfile webhook subscription
parameters:
- $ref: "#/components/parameters/ApiVersionHeader"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookSubscriptionRequest"
responses:
"200":
description: Created webhook subscription
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookSubscriptionResponse"
delete:
operationId: deleteWebhookSubscription
summary: Delete a server-side relayfile webhook subscription
parameters:
- $ref: "#/components/parameters/ApiVersionHeader"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DeleteWebhookSubscriptionRequest"
responses:
"200":
description: Subscription deleted
content:
application/json:
schema:
$ref: "#/components/schemas/DeleteWebhookSubscriptionResponse"
components:
parameters:
ApiVersionHeader:
Expand All @@ -237,7 +274,7 @@ components:
schema:
type: integer
format: uint32
const: 1
const: 2
ApiVersionQuery:
name: apiVersion
in: query
Expand Down Expand Up @@ -485,6 +522,44 @@ components:
type: string
secret:
type: string
WebhookSubscriptionRequest:
type: object
required: [url, pathGlobs, secret]
properties:
workspace:
type: string
url:
type: string
format: uri
pathGlobs:
type: array
minItems: 1
items:
type: string
secret:
type: string
WebhookSubscriptionResponse:
type: object
required: [subscriptionId]
properties:
subscriptionId:
type: string
secret:
type: string
DeleteWebhookSubscriptionRequest:
type: object
required: [subscriptionId]
properties:
workspace:
type: string
subscriptionId:
type: string
DeleteWebhookSubscriptionResponse:
type: object
required: [ok]
properties:
ok:
type: boolean
ErrorEnvelope:
type: object
required: [error]
Expand Down
40 changes: 38 additions & 2 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ describe('RelayfileControlPlaneClient lifecycle', () => {
const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false });
vi.spyOn(client, 'hello').mockResolvedValue({
daemonVersion: '0.10.17',
apiVersion: 2,
supportedApiVersions: [2],
apiVersion: 1,
supportedApiVersions: [1],
});
await expect(client.ensureReady()).rejects.toMatchObject({ code: 'VERSION_INCOMPATIBLE' });
});
Expand Down Expand Up @@ -106,6 +106,42 @@ describe('RelayfileControlPlaneClient lifecycle', () => {
});
});

describe('RelayfileControlPlaneClient integration webhook subscriptions', () => {
it('posts and deletes webhook subscriptions through the control-plane socket', async () => {
const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false });
vi.spyOn(client, 'ensureReady').mockResolvedValue(undefined);
const rawRequest = vi.spyOn(client as unknown as { rawRequest: (opts: unknown) => Promise<unknown> }, 'rawRequest');
rawRequest.mockResolvedValueOnce({ subscriptionId: 'whsub_123' }).mockResolvedValueOnce({ ok: true });

await expect(
client.createWebhookSubscription({
workspace: 'demo',
url: 'https://cast.test/v1/integrations/relayfile/inbound/ws/ch',
pathGlobs: ['/github/repos/acme/widgets/issues/**'],
secret: 'inbound-secret',
})
).resolves.toEqual({ subscriptionId: 'whsub_123' });

await expect(client.deleteWebhookSubscription('whsub_123', 'demo')).resolves.toEqual({ ok: true });

expect(rawRequest).toHaveBeenNthCalledWith(1, {
method: 'POST',
path: '/v1/integrations/webhook-subscriptions',
body: {
workspace: 'demo',
url: 'https://cast.test/v1/integrations/relayfile/inbound/ws/ch',
pathGlobs: ['/github/repos/acme/widgets/issues/**'],
secret: 'inbound-secret',
},
});
expect(rawRequest).toHaveBeenNthCalledWith(2, {
method: 'DELETE',
path: '/v1/integrations/webhook-subscriptions',
body: { subscriptionId: 'whsub_123', workspace: 'demo' },
});
});
});

// Real-daemon contract tests — opt-in via RELAYFILE_BIN (CI builds the binary:
// `go build -o relayfile ./cmd/relayfile-cli`). Boots the daemon and drives the
// client over the socket.
Expand Down
Loading
Loading