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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ jobs:
- name: Test
run: cd server && go test ./...

# The outbound-webhook dispatcher runs worker-pool goroutines; run its
# packages under the race detector so concurrency regressions are caught
# (the full suite isn't run with -race to keep CI fast).
- name: Test (race) — webhook delivery
run: cd server && go test -race ./internal/integrations/outwebhook/... ./internal/netguard/...

installer:
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
# backend job so installer regressions surface independently, and
Expand Down
63 changes: 63 additions & 0 deletions packages/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ import type {
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
WebhookSubscription,
CreateWebhookSubscriptionRequest,
UpdateWebhookSubscriptionRequest,
ListWebhookSubscriptionsResponse,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
Expand Down Expand Up @@ -179,6 +183,10 @@ import {
TimelineEntriesSchema,
UserSchema,
WebhookDeliveryResponseSchema,
WebhookSubscriptionResponseSchema,
ListWebhookSubscriptionsResponseSchema,
EMPTY_LIST_WEBHOOK_SUBSCRIPTIONS_RESPONSE,
EMPTY_WEBHOOK_SUBSCRIPTION,
BillingBalanceSchema,
BillingTransactionsPageSchema,
BillingBatchesPageSchema,
Expand Down Expand Up @@ -2080,6 +2088,61 @@ export class ApiClient {
);
}

// Outbound webhook subscriptions (workspace + project level).
async listWebhookSubscriptions(
projectId?: string,
): Promise<ListWebhookSubscriptionsResponse> {
const search = new URLSearchParams();
if (projectId) search.set("project_id", projectId);
const qs = search.toString();
const raw = await this.fetch<unknown>(
`/api/webhook-subscriptions${qs ? `?${qs}` : ""}`,
);
return parseWithFallback(
raw,
ListWebhookSubscriptionsResponseSchema,
EMPTY_LIST_WEBHOOK_SUBSCRIPTIONS_RESPONSE,
{ endpoint: "GET /api/webhook-subscriptions" },
);
}

async createWebhookSubscription(
data: CreateWebhookSubscriptionRequest,
): Promise<WebhookSubscription> {
const raw = await this.fetch<unknown>(`/api/webhook-subscriptions`, {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(
raw,
WebhookSubscriptionResponseSchema,
EMPTY_WEBHOOK_SUBSCRIPTION,
// redact the one-time signing secret so a schema drift can't log a live
// credential via parseWithFallback's `received` body.
{ endpoint: "POST /api/webhook-subscriptions", redact: ["secret"] },
);
}

async updateWebhookSubscription(
id: string,
data: UpdateWebhookSubscriptionRequest,
): Promise<WebhookSubscription> {
const raw = await this.fetch<unknown>(`/api/webhook-subscriptions/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
return parseWithFallback(
raw,
WebhookSubscriptionResponseSchema,
{ ...EMPTY_WEBHOOK_SUBSCRIPTION, id },
{ endpoint: "PATCH /api/webhook-subscriptions/:id" },
);
}

async deleteWebhookSubscription(id: string): Promise<void> {
await this.fetch(`/api/webhook-subscriptions/${id}`, { method: "DELETE" });
}

// GitHub integration
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
Expand Down
33 changes: 32 additions & 1 deletion packages/core/api/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { ApiClient } from "./client";
import { parseWithFallback } from "./schema";
import { parseWithFallback, setSchemaLogger } from "./schema";

// Helper: stub fetch with a single JSON response. Status defaults to 200.
function stubFetchJson(body: unknown, status = 200) {
Expand Down Expand Up @@ -343,4 +343,35 @@ describe("parseWithFallback", () => {
const out = parseWithFallback(null, schema, fallback, opts);
expect(out).toBe(fallback);
});

it("redacts named keys from the logged body on validation failure", () => {
const logged: unknown[] = [];
setSchemaLogger({
debug: () => {},
info: () => {},
warn: (_msg: string, ...data: unknown[]) => logged.push(...data),
error: () => {},
});
try {
const schema = z.object({ id: z.string() });
// `secret` present but `id` wrong type → validation fails → logs body.
parseWithFallback(
{ id: 123, secret: "whsec_live_credential" },
schema,
{ id: "fallback" },
{ endpoint: "POST /unit", redact: ["secret"] },
);
} finally {
setSchemaLogger({
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
});
}
const entry = logged[0] as { received?: Record<string, unknown> };
expect(entry.received?.secret).toBe("[redacted]");
// non-redacted fields are preserved for debugging
expect(entry.received?.id).toBe(123);
});
});
19 changes: 18 additions & 1 deletion packages/core/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ export interface ParseOptions {
/** Endpoint identifier used in the warning log so we can grep for which
* contract drifted in production telemetry. */
endpoint: string;
/** Top-level keys to redact from the logged `received` body on validation
* failure. Use for secret-bearing responses (e.g. a one-time signing
* secret) so a future shape drift can't write a live credential to logs. */
redact?: string[];
}

// redactReceived returns a shallow copy of an object body with the named keys
// replaced by "[redacted]". Non-object bodies are returned unchanged.
function redactReceived(data: unknown, keys: string[]): unknown {
if (keys.length === 0 || data === null || typeof data !== "object" || Array.isArray(data)) {
return data;
}
const copy: Record<string, unknown> = { ...(data as Record<string, unknown>) };
for (const k of keys) {
if (k in copy) copy[k] = "[redacted]";
}
return copy;
}

/**
Expand Down Expand Up @@ -48,7 +65,7 @@ export function parseWithFallback<T>(
{
endpoint: opts.endpoint,
issues: result.error.issues,
received: data,
received: redactReceived(data, opts.redact ?? []),
},
);
return fallback;
Expand Down
40 changes: 40 additions & 0 deletions packages/core/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import type {
TimelineEntry,
User,
WebhookDelivery,
WebhookSubscription,
ListWebhookSubscriptionsResponse,
} from "../types";
import type { CloudRuntimeNode } from "../runtimes/cloud-runtime";

Expand Down Expand Up @@ -753,6 +755,44 @@ export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
created_at: "",
};

// Outbound webhook subscriptions. Lenient per API Response Compatibility:
// enums stay z.string() (so a future event type doesn't fail the parse),
// nullable fields union with null, `.loose()` lets unknown server fields pass.
const WebhookSubscriptionSchema = z.object({
id: z.string(),
workspace_id: z.string(),
project_id: z.string().nullable(),
url: z.string(),
events: z.array(z.string()).default([]),
enabled: z.boolean().default(true),
secret_hint: z.string().default(""),
secret: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
}).loose();

export const WebhookSubscriptionResponseSchema = WebhookSubscriptionSchema;

export const ListWebhookSubscriptionsResponseSchema = z.object({
subscriptions: z.array(WebhookSubscriptionSchema).default([]),
}).loose();

export const EMPTY_LIST_WEBHOOK_SUBSCRIPTIONS_RESPONSE: ListWebhookSubscriptionsResponse = {
subscriptions: [],
};

export const EMPTY_WEBHOOK_SUBSCRIPTION: WebhookSubscription = {
id: "",
workspace_id: "",
project_id: null,
url: "",
events: [],
enabled: true,
secret_hint: "",
created_at: "",
updated_at: "",
};

// ---------------------------------------------------------------------------
// User (`/api/me` GET + PATCH). The auth store and Settings → Account both
// trust this shape — a drift here would knock both surfaces out. Kept
Expand Down
86 changes: 86 additions & 0 deletions packages/core/api/webhook-subscription.schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
WebhookSubscriptionResponseSchema,
ListWebhookSubscriptionsResponseSchema,
EMPTY_LIST_WEBHOOK_SUBSCRIPTIONS_RESPONSE,
EMPTY_WEBHOOK_SUBSCRIPTION,
} from "./schemas";
import { parseWithFallback } from "./schema";

const baseSubscription = {
id: "11111111-1111-1111-1111-111111111111",
workspace_id: "ws-1",
project_id: null,
url: "https://example.com/hook",
events: ["issue.status_changed"],
enabled: true,
secret_hint: "ab12",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};

describe("WebhookSubscription schemas", () => {
it("parses a well-formed subscription", () => {
const parsed = parseWithFallback(
baseSubscription,
WebhookSubscriptionResponseSchema,
EMPTY_WEBHOOK_SUBSCRIPTION,
{ endpoint: "test" },
);
expect(parsed.url).toBe("https://example.com/hook");
expect(parsed.events).toEqual(["issue.status_changed"]);
expect(parsed.enabled).toBe(true);
});

it("keeps unknown server fields and unknown event types (lenient)", () => {
const parsed = parseWithFallback(
{
subscriptions: [
{
...baseSubscription,
events: ["issue.status_changed", "issue.future_event"],
future_field: "kept",
},
],
},
ListWebhookSubscriptionsResponseSchema,
EMPTY_LIST_WEBHOOK_SUBSCRIPTIONS_RESPONSE,
{ endpoint: "test" },
);
expect(parsed.subscriptions).toHaveLength(1);
expect(parsed.subscriptions[0]?.events).toContain("issue.future_event");
expect(
(parsed.subscriptions[0] as unknown as Record<string, unknown>).future_field,
).toBe("kept");
});

it("defaults a missing subscriptions array to empty", () => {
const parsed = parseWithFallback(
{},
ListWebhookSubscriptionsResponseSchema,
EMPTY_LIST_WEBHOOK_SUBSCRIPTIONS_RESPONSE,
{ endpoint: "test" },
);
expect(parsed.subscriptions).toEqual([]);
});

it("falls back when subscriptions is the wrong type (null array)", () => {
const parsed = parseWithFallback(
{ subscriptions: null },
ListWebhookSubscriptionsResponseSchema,
EMPTY_LIST_WEBHOOK_SUBSCRIPTIONS_RESPONSE,
{ endpoint: "test" },
);
expect(parsed).toEqual(EMPTY_LIST_WEBHOOK_SUBSCRIPTIONS_RESPONSE);
});

it("falls back to the empty subscription when a required field is the wrong type", () => {
const parsed = parseWithFallback(
{ ...baseSubscription, url: 12345 },
WebhookSubscriptionResponseSchema,
EMPTY_WEBHOOK_SUBSCRIPTION,
{ endpoint: "test" },
);
expect(parsed).toEqual(EMPTY_WEBHOOK_SUBSCRIPTION);
});
});
3 changes: 3 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
"./autopilots": "./autopilots/index.ts",
"./autopilots/queries": "./autopilots/queries.ts",
"./autopilots/mutations": "./autopilots/mutations.ts",
"./webhooks": "./webhooks/index.ts",
"./webhooks/queries": "./webhooks/queries.ts",
"./webhooks/mutations": "./webhooks/mutations.ts",
"./pins": "./pins/index.ts",
"./pins/queries": "./pins/queries.ts",
"./pins/mutations": "./pins/mutations.ts",
Expand Down
7 changes: 7 additions & 0 deletions packages/core/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ export type {
WebhookSignatureStatus,
ListWebhookDeliveriesResponse,
} from "./autopilot";
export type {
WebhookSubscription,
WebhookSubscriptionEvent,
CreateWebhookSubscriptionRequest,
UpdateWebhookSubscriptionRequest,
ListWebhookSubscriptionsResponse,
} from "./webhook-subscription";
export type {
Squad,
SquadMember,
Expand Down
41 changes: 41 additions & 0 deletions packages/core/types/webhook-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Outbound webhook subscriptions: external HTTP endpoints Multica POSTs to when
// subscribed issue events fire. Modeled on GitHub org/repo webhooks —
// project_id null = workspace-level ("org"), project_id set = project-level
// ("repo"). See server migration 121 + handler/webhook_subscription.go.

// v1 emits a single event type. Kept as a string union (open to extension) so
// the UI can render new event types from the server without a code change.
export type WebhookSubscriptionEvent = "issue.status_changed";

export interface WebhookSubscription {
id: string;
workspace_id: string;
// null for workspace-level subscriptions.
project_id: string | null;
url: string;
events: string[];
enabled: boolean;
// Last 4 chars of the signing secret, to tell two secrets apart in the UI.
secret_hint: string;
// Full signing secret — present ONLY in the create response, shown once.
secret?: string;
created_at: string;
updated_at: string;
}

export interface CreateWebhookSubscriptionRequest {
url: string;
// Omit (or null) for a workspace-level webhook; set to scope to one project.
project_id?: string | null;
events?: string[];
}

export interface UpdateWebhookSubscriptionRequest {
url?: string;
events?: string[];
enabled?: boolean;
}

export interface ListWebhookSubscriptionsResponse {
subscriptions: WebhookSubscription[];
}
6 changes: 6 additions & 0 deletions packages/core/webhooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { webhookKeys, webhookSubscriptionsOptions } from "./queries";
export {
useCreateWebhookSubscription,
useUpdateWebhookSubscription,
useDeleteWebhookSubscription,
} from "./mutations";
Loading
Loading