diff --git a/packages/sdk-types/src/index.ts b/packages/sdk-types/src/index.ts index 28506f41..2fb90099 100644 --- a/packages/sdk-types/src/index.ts +++ b/packages/sdk-types/src/index.ts @@ -617,6 +617,28 @@ export type ConnectorRequestResult = { finalUrl: string; }; +/** Connector-6 — the one-time result of registering (or rotating) a + * mapping's inbound webhook endpoint. The URLs embed the per-endpoint + * secret and are shown ONCE — the shell stores only a hash, and no later + * call (status included) can return the secret again. */ +export type ConnectorWebhookEndpoint = { + routeId: string; + /** `/wh//` — appended to whichever base is reachable. */ + endpointPath: string; + loopbackUrl: string | null; + relayUrl: string | null; +}; + +/** Connector-6 — a mapping's webhook endpoint state. Never carries the + * secret. */ +export type ConnectorWebhookStatus = { + registered: boolean; + routeId: string | null; + createdAt: number | null; + loopbackBaseUrl: string | null; + relayBaseUrl: string | null; +}; + /** App-facing connector broker (doc 56). The shell owns OAuth, token * custody, and egress; the app holds only entity refs. */ export type ConnectorsService = { @@ -644,6 +666,15 @@ export type ConnectorsService = { body?: unknown; headers?: Record; }): Promise; + /** Connector-6 — mint (or rotate: re-mint replaces) the mapping's inbound + * webhook endpoint. An authenticated hit runs `connectors.sync` on the + * mapping — the payload is discarded (doorbell semantics). Requires the + * runtime `network.ingress` grant on the connector app; fail-closed. */ + webhookRegister(input: { mappingRef: string }): Promise; + /** Connector-6 — the mapping's endpoint state (never the secret). */ + webhookStatus(input: { mappingRef: string }): Promise; + /** Connector-6 — kill the mapping's endpoint (the URL goes dead live). */ + webhookRevoke(input: { mappingRef: string }): Promise<{ ok: true; removed: boolean }>; }; // ─── Mail service (shell-side transport + sync — doc 53, Mailbox-5) ───────── diff --git a/packages/sdk/src/runtime.ts b/packages/sdk/src/runtime.ts index 3a20da16..b850b7fa 100644 --- a/packages/sdk/src/runtime.ts +++ b/packages/sdk/src/runtime.ts @@ -32,6 +32,8 @@ import type { CalDavService, CalDavSyncSummary, ConnectorRequestResult, + ConnectorWebhookEndpoint, + ConnectorWebhookStatus, ConnectorsService, ContributedAction, ContributedActionTarget, @@ -1243,6 +1245,30 @@ function connectorsProxy(bridge: Bridge): ConnectorsService { ), sync: (input) => callService(bridge, "connectors", "sync", [input], ["connectors.request"]), + webhookRegister: (input) => + callService( + bridge, + "connectors", + "webhookRegister", + [input], + ["connectors.webhook"], + ), + webhookStatus: (input) => + callService( + bridge, + "connectors", + "webhookStatus", + [input], + ["connectors.webhook"], + ), + webhookRevoke: (input) => + callService<{ ok: true; removed: boolean }>( + bridge, + "connectors", + "webhookRevoke", + [input], + ["connectors.webhook"], + ), }; } diff --git a/packages/shell/src/main/automations/automations-host.ts b/packages/shell/src/main/automations/automations-host.ts index fe47f1bc..8d1ceda0 100644 --- a/packages/shell/src/main/automations/automations-host.ts +++ b/packages/shell/src/main/automations/automations-host.ts @@ -66,10 +66,49 @@ export type WebhookTrigger = { secret: string; }; +/** What an authenticated inbound webhook dispatches to (Connector-6). */ +export enum WebhookTargetKind { + /** 11b.8 — run a workflow under its own frozen caps. */ + Workflow = "workflow", + /** Connector-6 — run `connectors.sync(mappingRef)` (doorbell semantics: + * the payload is discarded; the provider delta API is the source of + * truth, so a hostile body can never enter the projection). */ + ConnectorSync = "connector-sync", +} + +/** A webhook route as the ingress planes hold it: the path key, the secret + * custody (plaintext for workflow triggers — the `Trigger/v1` entity owns + * that secret — or a SHA-256 digest for connector endpoints, which are + * hash-only at rest), and the dispatch target. A route with neither + * verifier authenticates nothing (fail-closed). */ +export type WebhookRoute = { + routeId: string; + targetKind: WebhookTargetKind; + targetId: string; + /** Plaintext shared secret (workflow triggers). */ + secret?: string; + /** Hex SHA-256 of the secret (connector endpoints — plaintext is never + * stored; the ingress plane hashes the presented secret to compare). */ + secretSha256?: string; +}; + +/** Connector-6 — one connector webhook endpoint, as the wiring reads it from + * the registry store: an authenticated hit on `routeId` runs + * `connectors.sync(mappingId)`. Shell-internal (the digest never reaches an + * app); `connectorAppId` is the app whose `network.ingress` grant gates the + * route. */ +export type ConnectorWebhookRegistration = { + mappingId: string; + routeId: string; + secretSha256: string; + connectorAppId: string; +}; + /** A verified inbound webhook, as the ingress plane hands it to the host — * the secret has already been matched, so it never rides the hit. */ export type WebhookHit = { - workflowId: string; + targetKind: WebhookTargetKind; + targetId: string; routeId: string; method: string; headers: Record; @@ -83,7 +122,7 @@ export type WebhookHit = { * fake and inert (undefined) without `network.ingress`. */ export type WebhookIngressPort = { /** Replace the active route set. Idempotent — re-registering overwrites. */ - register(routes: readonly WebhookTrigger[]): void; + register(routes: readonly WebhookRoute[]): void; /** Subscribe to authenticated inbound hits; returns an unsubscribe. */ subscribe(listener: (hit: WebhookHit) => void): () => void; }; @@ -126,6 +165,11 @@ export type ScheduleRegistration = { * hydrate; empty (and the plane inert) without `network.ingress`. Optional * like `syncMappings`/`itemAlerts` so prior callers need no change. */ webhooks?: WebhookTrigger[]; + /** Connector-6 — connector webhook endpoints. An authenticated hit is a + * doorbell for `connectors.sync(mappingId)` (payload discarded). The + * wiring derives these from the registry store, already filtered to + * connector apps holding `network.ingress` (fail-closed). Optional. */ + connectorWebhooks?: ConnectorWebhookRegistration[]; /** 11b.10 — file-watch triggers. Registered with the watch plane on hydrate; * each re-mints a live handle from its persistent grant. Optional. */ fileWatches?: FileWatchTrigger[]; @@ -168,6 +212,17 @@ export type AutomationsHostPorts = { reminderRunner: ReminderRunner; /** Connector-4 — the sync engine a `SyncMapping` fire routes to. */ connectorSync?: ConnectorSyncPort; + /** Connector-6 — re-check, against the LIVE ledger, that a webhook-driven + * sync is still permitted (the endpoint's connector app still holds + * `network.ingress`) immediately BEFORE each dispatch. Mirrors + * `appCapabilities`' per-fire thunk: a Settings revoke takes effect on the + * next inbound hit, not on the next entity-write re-derive — otherwise a + * revoked app's endpoint keeps pulling from the provider and writing + * entities for as long as nothing else happens to re-hydrate. Absent ⇒ the + * registered route set is the only gate (tests / partial wirings). Applies + * ONLY to the webhook path; Connector-4's SCHEDULED mapping fires are + * unaffected (they are not an ingress surface). */ + connectorWebhookAllowed?(mappingId: string): Promise; /** 9.14.9b — posts a due item alert through the shared ui notify host. * Absent keeps item alerts registered but silent (tests / partial * wirings), mirroring `connectorSync`'s optionality. */ @@ -207,6 +262,11 @@ export class AutomationsHost { private readonly itemAlertsById = new Map(); private entityEvents: EntityEventTrigger[] = []; private webhooks: WebhookTrigger[] = []; + private connectorWebhooks: ConnectorWebhookRegistration[] = []; + /** Per-mapping coalescer: a webhook storm collapses to one in-flight sync + * plus at most one trailing run (the rate bound — sync frequency is + * capped by sync duration, never by inbound request rate). */ + private readonly connectorSyncStates = new Map(); private webhookIngress: WebhookIngressPort | undefined; private fileWatches: FileWatchTrigger[] = []; private startups: string[] = []; @@ -235,8 +295,11 @@ export class AutomationsHost { this.unsubscribeWebhooks = null; this.webhookIngress = port; if (port) { - port.register(this.webhooks); - if (wasSubscribed) { + port.register(this.webhookRoutes()); + // Re-point a live subscription — and if the host is already running + // (a mid-session bind: the FIRST endpoint minted after start()), + // subscribe now or hits would be dropped until the next start(). + if (wasSubscribed || this.timer !== null) { this.unsubscribeWebhooks = port.subscribe((hit) => { void this.onWebhookHit(hit); }); @@ -255,7 +318,8 @@ export class AutomationsHost { this.itemAlertsById.clear(); this.entityEvents = [...registration.entityEvents]; this.webhooks = [...(registration.webhooks ?? [])]; - this.webhookIngress?.register(this.webhooks); + this.connectorWebhooks = [...(registration.connectorWebhooks ?? [])]; + this.webhookIngress?.register(this.webhookRoutes()); this.fileWatches = [...(registration.fileWatches ?? [])]; this.ports.fileWatch?.register(this.fileWatches); this.startups = [...(registration.startups ?? [])]; @@ -448,21 +512,90 @@ export class AutomationsHost { } } + /** The union route table the ingress planes verify against: workflow + * triggers (plaintext secret, owned by the `Trigger/v1` entity) plus + * connector endpoints (hash-only custody). */ + private webhookRoutes(): WebhookRoute[] { + return [ + ...this.webhooks.map((w) => ({ + routeId: w.routeId, + targetKind: WebhookTargetKind.Workflow, + targetId: w.workflowId, + secret: w.secret, + })), + ...this.connectorWebhooks.map((c) => ({ + routeId: c.routeId, + targetKind: WebhookTargetKind.ConnectorSync, + targetId: c.mappingId, + secretSha256: c.secretSha256, + })), + ]; + } + /** An authenticated inbound webhook (secret already verified by the ingress - * plane) fires its bound workflow under the workflow's own frozen caps. The - * request rides as the trigger payload so a `Code`/`AICall` step can read - * the body/headers. Guard on the registered route set so a hit for a route - * no longer registered (rehydrate race) is dropped. */ + * plane) fires its target. A workflow target runs under the workflow's own + * frozen caps with the request as trigger payload (so a `Code`/`AICall` + * step can read body/headers). A connector-sync target is a doorbell: the + * payload is DISCARDED and `connectors.sync(mappingId)` pulls from the + * provider's API instead — hostile body bytes can never enter the + * projection. Guard on the registered route set so a hit for a route no + * longer registered (rehydrate race / revoke) is dropped. */ private async onWebhookHit(hit: WebhookHit): Promise { - if (!this.webhooks.some((w) => w.workflowId === hit.workflowId && w.routeId === hit.routeId)) { + if (hit.targetKind === WebhookTargetKind.ConnectorSync) { + const registered = this.connectorWebhooks.some( + (c) => c.mappingId === hit.targetId && c.routeId === hit.routeId, + ); + if (registered) this.dispatchConnectorWebhookSync(hit.targetId); + return; + } + if (!this.webhooks.some((w) => w.workflowId === hit.targetId && w.routeId === hit.routeId)) { return; } - await this.runWorkflow(hit.workflowId, `webhook:${hit.routeId}`, { + await this.runWorkflow(hit.targetId, `webhook:${hit.routeId}`, { routeId: hit.routeId, method: hit.method, headers: hit.headers, body: hit.bodyText, - }).catch((e) => this.fail(`webhook ${hit.workflowId}`, e)); + }).catch((e) => this.fail(`webhook ${hit.targetId}`, e)); + } + + /** Coalesced `connectors.sync` dispatch: at most one in-flight sync per + * mapping, with one trailing run if hits arrived meanwhile. A failure + * drops the queued flag (the next hit re-arms) — never a retry storm. + * Every iteration re-checks the live grant first, so a mid-storm revoke + * stops the loop instead of riding it out. */ + private dispatchConnectorWebhookSync(mappingId: string): void { + const inFlight = this.connectorSyncStates.get(mappingId); + if (inFlight) { + inFlight.queued = true; + return; + } + const state = { queued: false }; + this.connectorSyncStates.set(mappingId, state); + void (async () => { + try { + do { + state.queued = false; + if (!(await this.connectorWebhookStillAllowed(mappingId))) break; + await this.ports.connectorSync?.runSync(mappingId); + } while (state.queued); + } catch (e) { + this.fail(`connector webhook sync ${mappingId}`, e); + } finally { + this.connectorSyncStates.delete(mappingId); + } + })(); + } + + /** Fail-closed live re-check of a connector endpoint's `network.ingress` + * grant. A throwing check denies — never approves. */ + private async connectorWebhookStillAllowed(mappingId: string): Promise { + if (!this.ports.connectorWebhookAllowed) return true; + try { + return await this.ports.connectorWebhookAllowed(mappingId); + } catch { + return false; + } } /** A watched file changed — fire its bound workflow under the workflow's own diff --git a/packages/shell/src/main/automations/connector-webhook-attack.test.ts b/packages/shell/src/main/automations/connector-webhook-attack.test.ts new file mode 100644 index 00000000..37ad2b80 --- /dev/null +++ b/packages/shell/src/main/automations/connector-webhook-attack.test.ts @@ -0,0 +1,471 @@ +/** + * Connector-6 — ADVERSARIAL probes against the connector webhook-in plane. + * + * These are the attacks the pentest gate ran, kept as regression tests: + * - route-table collision between a workflow trigger and a connector + * endpoint (cross-`WebhookTargetKind` confusion), + * - URL parsing (traversal, encoded separators, extra segments, query), + * - the 404/405 differentiation surface, + * - the body cap on a connector (doorbell) route, + * - per-mapping coalescing under a hit storm — trailing liveness and + * cross-mapping non-starvation, + * - `network.ingress` revocation liveness (a revoked app's endpoint must + * stop dispatching, not linger until the next entity write). + */ + +import type { CapabilityGrant, CapabilityLedger } from "@brainstorm-os/capabilities/ledger"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EntityChangeEmitter } from "../entities/entity-change-emitter"; +import { sha256Hex } from "../storage/registry-repo/connector-webhooks-repo"; +import { + AutomationsHost, + type ConnectorWebhookRegistration, + type ScheduleRegistration, + type WebhookHit, + type WebhookIngressPort, + WebhookTargetKind, + type WebhookTrigger, +} from "./automations-host"; +import type { PersistedFire, SchedulerStore } from "./scheduler-service"; +import { type WebhookLoopbackListener, createWebhookLoopbackListener } from "./webhook-listener"; +import { buildAutomationsDeployment } from "./wiring"; + +const CONNECTOR_APP = "io.x.github"; +const SECRET = "conn-secret-0123456789abcdefghijklmn"; +const WF_SECRET = "workflow-plaintext-secret"; + +function ledgerWith(perApp: Record): CapabilityLedger { + return { + listActive: (appId: string): CapabilityGrant[] => + (perApp[appId] ?? []).map( + (capability, i) => + ({ + id: `g${i}`, + appId, + capability, + scope: null, + grantedAt: 0, + grantedVia: "install", + }) as CapabilityGrant, + ), + has: (appId: string, capability: string) => (perApp[appId] ?? []).includes(capability), + } as unknown as CapabilityLedger; +} + +function memorySchedulerStore(): SchedulerStore { + const fires = new Map(); + return { + loadAll: () => [...fires.values()], + save: (f) => void fires.set(f.triggerId, f), + remove: (id) => void fires.delete(id), + }; +} + +function fakeIngress() { + const listeners = new Set<(hit: WebhookHit) => void>(); + const port: WebhookIngressPort = { + register: () => {}, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + return { + port, + push: (hit: WebhookHit) => { + for (const l of listeners) l(hit); + }, + }; +} + +function makeHost( + ingress: WebhookIngressPort, + connectorSync: { runSync: (mappingId: string) => Promise }, +): AutomationsHost { + return new AutomationsHost({ + scheduler: { tick: vi.fn(async () => []) } as never, + reminderRunner: { fire: vi.fn() } as never, + loadWorkflow: vi.fn(), + makeInterpreterPorts: vi.fn(), + persistRun: vi.fn(), + appCapabilities: [], + clock: () => 0, + webhookIngress: ingress, + intervals: { set: () => 0 as never, clear: () => {} }, + connectorSync, + onError: () => {}, + }); +} + +const registration = ( + webhooks: WebhookTrigger[], + connectorWebhooks: ConnectorWebhookRegistration[], +): ScheduleRegistration => ({ + workflows: [], + reminders: [], + entityEvents: [], + webhooks, + connectorWebhooks, +}); + +const connectorHit = (mappingId: string, routeId: string): WebhookHit => ({ + targetKind: WebhookTargetKind.ConnectorSync, + targetId: mappingId, + routeId, + method: "POST", + headers: {}, + bodyText: "", +}); + +const endpoint: ConnectorWebhookRegistration = { + mappingId: "map-1", + routeId: "cw_route1", + secretSha256: sha256Hex(SECRET), + connectorAppId: CONNECTOR_APP, +}; + +// ─── Ingress plane: parsing + differentiation + body cap ──────────────────── + +describe("Connector-6 attack — loopback URL parsing and response differentiation", () => { + let listener: WebhookLoopbackListener | null = null; + afterEach(async () => { + await listener?.close(); + listener = null; + }); + + async function start(): Promise<{ port: number; hits: WebhookHit[] }> { + listener = createWebhookLoopbackListener(); + const port = await listener.whenReady(); + const hits: WebhookHit[] = []; + listener.subscribe((h) => hits.push(h)); + listener.register([ + { + routeId: endpoint.routeId, + targetKind: WebhookTargetKind.ConnectorSync, + targetId: endpoint.mappingId, + secretSha256: endpoint.secretSha256, + }, + ]); + return { port, hits }; + } + + it("refuses every traversal / injection shape of the route + secret segments", async () => { + const { port, hits } = await start(); + const base = `http://127.0.0.1:${port}`; + const paths = [ + // extra path segments past the secret + `/wh/${endpoint.routeId}/${SECRET}/extra`, + // traversal in either segment + "/wh/../../etc/passwd", + `/wh/..%2f..%2fetc/${SECRET}`, + `/wh/${endpoint.routeId}/..%2f${SECRET}`, + // an encoded separator must NOT be decoded into a match + `/wh/${endpoint.routeId}%2f${SECRET}`, + // percent-encoded secret bytes must not decode into the real secret + `/wh/${endpoint.routeId}/%63onn-secret-0123456789abcdefghijklmn`, + // empty segments + `/wh//${SECRET}`, + `/wh/${endpoint.routeId}/`, + // a different prefix + `/WH/${endpoint.routeId}/${SECRET}`, + ]; + for (const path of paths) { + const res = await fetch(`${base}${path}`, { method: "POST" }); + expect({ path, status: res.status }).toEqual({ path, status: 404 }); + } + expect(hits).toHaveLength(0); + }); + + it("accepts a trailing slash and ignores the query string (path-only routing)", async () => { + const { port, hits } = await start(); + const base = `http://127.0.0.1:${port}`; + expect( + (await fetch(`${base}/wh/${endpoint.routeId}/${SECRET}/?x=1`, { method: "POST" })).status, + ).toBe(202); + await vi.waitFor(() => expect(hits).toHaveLength(1)); + expect(hits[0]?.targetKind).toBe(WebhookTargetKind.ConnectorSync); + }); + + it("405 is reachable ONLY with the correct secret — an unauthenticated non-POST is a plain 404", async () => { + const { port, hits } = await start(); + const base = `http://127.0.0.1:${port}`; + for (const method of ["GET", "PUT", "DELETE", "PATCH", "OPTIONS"]) { + // unknown route + expect((await fetch(`${base}/wh/nope/${SECRET}`, { method })).status).toBe(404); + // known route, wrong secret + expect((await fetch(`${base}/wh/${endpoint.routeId}/wrong`, { method })).status).toBe(404); + // known route + secret: the method check is the only thing that shows + expect((await fetch(`${base}/wh/${endpoint.routeId}/${SECRET}`, { method })).status).toBe(405); + } + expect(hits).toHaveLength(0); + }); + + it("unknown route and wrong secret are byte-identical responses (no oracle in headers/body)", async () => { + const { port } = await start(); + const base = `http://127.0.0.1:${port}`; + const shape = async (path: string) => { + const res = await fetch(`${base}${path}`, { method: "POST" }); + const headers = [...res.headers.entries()] + .filter(([k]) => k !== "date") + .sort() + .map(([k, v]) => `${k}: ${v}`); + return { status: res.status, headers, body: await res.text() }; + }; + expect(await shape(`/wh/cw_doesnotexist/${SECRET}`)).toEqual( + await shape(`/wh/${endpoint.routeId}/definitely-not-the-secret`), + ); + }); + + it("an over-cap body on a doorbell route is 413 and dispatches nothing", async () => { + const { port, hits } = await start(); + const res = await fetch(`http://127.0.0.1:${port}/wh/${endpoint.routeId}/${SECRET}`, { + method: "POST", + body: "x".repeat(256 * 1024 + 1), + }); + expect(res.status).toBe(413); + await new Promise((r) => setTimeout(r, 20)); + expect(hits).toHaveLength(0); + }); +}); + +// ─── Cross-target confusion ──────────────────────────────────────────────── + +describe("Connector-6 attack — WebhookTargetKind confusion", () => { + let listener: WebhookLoopbackListener | null = null; + afterEach(async () => { + await listener?.close(); + listener = null; + }); + + it("a workflow Trigger entity claiming a connector's routeId cannot hijack or shadow the endpoint", async () => { + // A malicious `Trigger/v1` entity is app-authored: it can name ANY + // routeId and its own plaintext secret. The connector endpoint must win + // the route table, and the attacker's secret must not authenticate. + const host = makeHost({ port: { register: () => {}, subscribe: () => () => {} } }.port, { + runSync: async () => null, + }); + const captured: unknown[] = []; + host.setWebhookIngress({ + register: (routes) => captured.push([...routes]), + subscribe: () => () => {}, + }); + await host.hydrate( + registration( + [{ workflowId: "wf-evil", routeId: endpoint.routeId, secret: WF_SECRET }], + [endpoint], + ), + 0, + ); + const table = captured.at(-1) as Array<{ routeId: string; targetKind: WebhookTargetKind }>; + const forRoute = table.filter((r) => r.routeId === endpoint.routeId); + // Both entries are handed over, but the ingress planes key by routeId + // and the connector entry is registered LAST — it wins. + listener = createWebhookLoopbackListener(); + const port = await listener.whenReady(); + const hits: WebhookHit[] = []; + listener.subscribe((h) => hits.push(h)); + listener.register(table as never); + + // The attacker's own plaintext secret must not open the connector route. + expect( + ( + await fetch(`http://127.0.0.1:${port}/wh/${endpoint.routeId}/${WF_SECRET}`, { + method: "POST", + }) + ).status, + ).toBe(404); + // The connector secret still works and still routes to ConnectorSync. + expect( + ( + await fetch(`http://127.0.0.1:${port}/wh/${endpoint.routeId}/${SECRET}`, { + method: "POST", + }) + ).status, + ).toBe(202); + await vi.waitFor(() => expect(hits).toHaveLength(1)); + expect(hits[0]?.targetKind).toBe(WebhookTargetKind.ConnectorSync); + expect(hits[0]?.targetId).toBe(endpoint.mappingId); + expect(forRoute.at(-1)?.targetKind).toBe(WebhookTargetKind.ConnectorSync); + }); + + it("a ConnectorSync hit whose targetId names a WORKFLOW never runs a workflow", async () => { + const ingress = fakeIngress(); + const runSync = vi.fn(async () => null); + const host = makeHost(ingress.port, { runSync }); + const runWorkflow = vi.fn(async () => null); + (host as unknown as { runWorkflow: unknown }).runWorkflow = runWorkflow; + await host.hydrate( + registration([{ workflowId: "wf1", routeId: "r1", secret: WF_SECRET }], [endpoint]), + 0, + ); + host.start(); + // Forge a hit that claims ConnectorSync but names the workflow id/route. + ingress.push(connectorHit("wf1", "r1")); + await new Promise((r) => setTimeout(r, 20)); + expect(runWorkflow).not.toHaveBeenCalled(); + expect(runSync).not.toHaveBeenCalled(); + host.stop(); + }); + + it("a Workflow-kind hit naming a connector mapping/route never dispatches a sync", async () => { + const ingress = fakeIngress(); + const runSync = vi.fn(async () => null); + const host = makeHost(ingress.port, { runSync }); + const runWorkflow = vi.fn(async () => null); + (host as unknown as { runWorkflow: unknown }).runWorkflow = runWorkflow; + await host.hydrate(registration([], [endpoint]), 0); + host.start(); + ingress.push({ + targetKind: WebhookTargetKind.Workflow, + targetId: endpoint.mappingId, + routeId: endpoint.routeId, + method: "POST", + headers: {}, + bodyText: "", + }); + await new Promise((r) => setTimeout(r, 20)); + expect(runWorkflow).not.toHaveBeenCalled(); + expect(runSync).not.toHaveBeenCalled(); + host.stop(); + }); +}); + +// ─── Coalescer: trailing liveness + cross-mapping non-starvation ──────────── + +describe("Connector-6 attack — coalescer under a hit storm", () => { + it("a 500-hit flood on one mapping collapses to 2 runs and never starves a sibling mapping", async () => { + const ingress = fakeIngress(); + const started: string[] = []; + // A holder, not a `let` — TS narrows a `let` assigned only inside a + // callback to `null` and the later call fails to typecheck. + const gate: { release: (() => void) | null } = { release: null }; + const runSync = vi.fn(async (mappingId: string) => { + started.push(mappingId); + if (mappingId === "map-1" && started.filter((m) => m === "map-1").length === 1) { + await new Promise((resolve) => { + gate.release = resolve; + }); + } + return null; + }); + const host = makeHost(ingress.port, { runSync }); + const second: ConnectorWebhookRegistration = { + mappingId: "map-2", + routeId: "cw_route2", + secretSha256: sha256Hex("other"), + connectorAppId: CONNECTOR_APP, + }; + await host.hydrate(registration([], [endpoint, second]), 0); + host.start(); + + for (let i = 0; i < 500; i += 1) ingress.push(connectorHit("map-1", "cw_route1")); + await vi.waitFor(() => expect(started.filter((m) => m === "map-1")).toHaveLength(1)); + + // A sibling mapping is NOT blocked by map-1's in-flight sync. + ingress.push(connectorHit("map-2", "cw_route2")); + await vi.waitFor(() => expect(started).toContain("map-2")); + + gate.release?.(); + // Exactly one trailing run for map-1 — the flood does not amplify. + await vi.waitFor(() => expect(started.filter((m) => m === "map-1")).toHaveLength(2)); + await new Promise((r) => setTimeout(r, 20)); + expect(started.filter((m) => m === "map-1")).toHaveLength(2); + + // No per-mapping state is retained after the loop drains. + const states = (host as unknown as { connectorSyncStates: Map }) + .connectorSyncStates; + await vi.waitFor(() => expect(states.size).toBe(0)); + host.stop(); + }); +}); + +// ─── Grant revocation liveness ───────────────────────────────────────────── + +describe("Connector-6 attack — network.ingress revocation liveness", () => { + it("a live endpoint stops dispatching the moment the connector app loses network.ingress", async () => { + let grants: Record = { [CONNECTOR_APP]: ["network.ingress"] }; + const runSync = vi.fn(async () => ({})); + const dep = buildAutomationsDeployment({ + callEntities: async (method) => (method === "query" ? [] : null), + getServiceHandler: () => undefined, + getLedger: async () => ledgerWith(grants), + schedulerStore: memorySchedulerStore(), + entityChanges: new EntityChangeEmitter(), + notify: () => {}, + deviceId: "device-A", + connectorSync: { runSync }, + listConnectorWebhooks: async () => [endpoint], + intervals: { set: () => 0 as unknown as ReturnType, clear: () => {} }, + }); + await dep.start(); + try { + let base: string | null = null; + await vi.waitFor(async () => { + base = (await dep.webhookInfo()).loopbackBaseUrl; + expect(base).not.toBeNull(); + }); + const url = `${base as unknown as string}/wh/${endpoint.routeId}/${SECRET}`; + expect((await fetch(url, { method: "POST" })).status).toBe(202); + await vi.waitFor(() => expect(runSync).toHaveBeenCalledTimes(1)); + + // The user revokes `network.ingress` from the connector app in + // Settings. Nothing rewrites an automation entity, so nothing + // re-derives the schedule on its own — the endpoint must still stop + // pulling from the provider on the very next hit (the host's per-fire + // live re-check), even though the socket has not been re-hydrated. + grants = {}; + expect((await fetch(url, { method: "POST" })).status).toBe(202); + await new Promise((r) => setTimeout(r, 30)); + expect(runSync).toHaveBeenCalledTimes(1); + + // And once the revoke re-derives the route table (what + // `ledger:revoke`'s `onGrantsChanged` hook now does), the URL is dead. + await dep.rehydrate(); + expect((await fetch(url, { method: "POST" })).status).toBe(404); + await new Promise((r) => setTimeout(r, 30)); + expect(runSync).toHaveBeenCalledTimes(1); + } finally { + dep.stop(); + } + }); + + it("a revoke landing MID-STORM stops the coalescer loop instead of riding out the queue", async () => { + const ingress = fakeIngress(); + let allowed = true; + const runSync = vi.fn(async () => null); + const host = makeHost(ingress.port, { runSync }); + (host as unknown as { ports: { connectorWebhookAllowed: unknown } }).ports = { + ...(host as unknown as { ports: object }).ports, + connectorWebhookAllowed: async () => allowed, + }; + await host.hydrate(registration([], [endpoint]), 0); + host.start(); + + ingress.push(connectorHit("map-1", "cw_route1")); + await vi.waitFor(() => expect(runSync).toHaveBeenCalledTimes(1)); + // Queue a trailing run, then revoke before it starts. + allowed = false; + ingress.push(connectorHit("map-1", "cw_route1")); + await new Promise((r) => setTimeout(r, 30)); + expect(runSync).toHaveBeenCalledTimes(1); + host.stop(); + }); + + it("a throwing grant check denies (fail-closed), never approves", async () => { + const ingress = fakeIngress(); + const runSync = vi.fn(async () => null); + const host = makeHost(ingress.port, { runSync }); + (host as unknown as { ports: { connectorWebhookAllowed: unknown } }).ports = { + ...(host as unknown as { ports: object }).ports, + connectorWebhookAllowed: async () => { + throw new Error("ledger unavailable"); + }, + }; + await host.hydrate(registration([], [endpoint]), 0); + host.start(); + ingress.push(connectorHit("map-1", "cw_route1")); + await new Promise((r) => setTimeout(r, 30)); + expect(runSync).not.toHaveBeenCalled(); + host.stop(); + }); +}); diff --git a/packages/shell/src/main/automations/connector-webhook-wiring.test.ts b/packages/shell/src/main/automations/connector-webhook-wiring.test.ts new file mode 100644 index 00000000..92873d53 --- /dev/null +++ b/packages/shell/src/main/automations/connector-webhook-wiring.test.ts @@ -0,0 +1,170 @@ +/** + * Connector-6 — the deployment-level connector webhook plane, end to end over + * a REAL loopback listener: registry-store routes register only for connector + * apps holding `network.ingress` (fail-closed), an authenticated inbound POST + * dispatches `connectors.sync(mappingId)` (payload discarded), and a revoke + * takes effect live via `rehydrate()` (the URL goes dead). + */ + +import type { CapabilityGrant, CapabilityLedger } from "@brainstorm-os/capabilities/ledger"; +import { describe, expect, it, vi } from "vitest"; +import { EntityChangeEmitter } from "../entities/entity-change-emitter"; +import { sha256Hex } from "../storage/registry-repo/connector-webhooks-repo"; +import type { ConnectorWebhookRegistration } from "./automations-host"; +import type { PersistedFire, SchedulerStore } from "./scheduler-service"; +import { buildAutomationsDeployment } from "./wiring"; + +const CONNECTOR_APP = "io.x.github"; +const SECRET = "conn-secret-0123456789abcdefghijklmn"; + +function ledgerWith(perApp: Record): CapabilityLedger { + return { + listActive: (appId: string): CapabilityGrant[] => + (perApp[appId] ?? []).map( + (capability, i) => + ({ + id: `g${i}`, + appId, + capability, + scope: null, + grantedAt: 0, + grantedVia: "install", + }) as CapabilityGrant, + ), + has: (appId: string, capability: string) => (perApp[appId] ?? []).includes(capability), + } as unknown as CapabilityLedger; +} + +function memorySchedulerStore(): SchedulerStore { + const fires = new Map(); + return { + loadAll: () => [...fires.values()], + save: (f) => void fires.set(f.triggerId, f), + remove: (id) => void fires.delete(id), + }; +} + +function deployment(input: { + endpoints: () => readonly ConnectorWebhookRegistration[]; + grants: Record; + runSync?: (mappingId: string) => Promise; +}) { + const runSync = vi.fn(input.runSync ?? (async () => ({}))); + const dep = buildAutomationsDeployment({ + callEntities: async (method) => (method === "query" ? [] : null), + getServiceHandler: () => undefined, + getLedger: async () => ledgerWith(input.grants), + schedulerStore: memorySchedulerStore(), + entityChanges: new EntityChangeEmitter(), + notify: () => {}, + deviceId: "device-A", + connectorSync: { runSync }, + listConnectorWebhooks: async () => input.endpoints(), + intervals: { set: () => 0 as unknown as ReturnType, clear: () => {} }, + }); + return { dep, runSync }; +} + +/** The listener binds asynchronously after start()/rehydrate() — wait for the + * loopback base to surface before hitting it. */ +async function boundBaseUrl(dep: ReturnType): Promise { + let base: string | null = null; + await vi.waitFor(async () => { + base = (await dep.webhookInfo()).loopbackBaseUrl; + expect(base).not.toBeNull(); + }); + return base as unknown as string; +} + +const endpoint: ConnectorWebhookRegistration = { + mappingId: "map-1", + routeId: "cw_route1", + secretSha256: sha256Hex(SECRET), + connectorAppId: CONNECTOR_APP, +}; + +describe("connector webhook plane (Connector-6)", () => { + it("binds the shared listener for a granted connector endpoint (no automations grant) and dispatches sync on an authenticated POST", async () => { + const { dep, runSync } = deployment({ + endpoints: () => [endpoint], + // ONLY the connector app holds network.ingress — the automations app + // does not; the listener must still come up for the connector plane. + grants: { [CONNECTOR_APP]: ["network.ingress"] }, + }); + await dep.start(); + try { + const base = await boundBaseUrl(dep); + + const ok = await fetch(`${base}/wh/cw_route1/${SECRET}`, { + method: "POST", + body: "doorbell", + }); + expect(ok.status).toBe(202); + await vi.waitFor(() => expect(runSync).toHaveBeenCalledTimes(1)); + expect(runSync).toHaveBeenCalledWith("map-1"); + + // Wrong secret: oracle-free 404, no dispatch. + const bad = await fetch(`${base}/wh/cw_route1/wrong`, { method: "POST" }); + expect(bad.status).toBe(404); + expect(runSync).toHaveBeenCalledTimes(1); + } finally { + dep.stop(); + } + }); + + it("fail-closed: without the connector app's network.ingress grant no route registers and no listener binds", async () => { + const { dep, runSync } = deployment({ + endpoints: () => [endpoint], + grants: {}, // nobody holds network.ingress + }); + await dep.start(); + try { + const info = await dep.webhookInfo(); + expect(info.loopbackBaseUrl).toBeNull(); + expect(runSync).not.toHaveBeenCalled(); + } finally { + dep.stop(); + } + }); + + it("revoke: rehydrate() after the store drops the endpoint kills the URL live", async () => { + let rows: readonly ConnectorWebhookRegistration[] = [endpoint]; + const { dep, runSync } = deployment({ + endpoints: () => rows, + grants: { [CONNECTOR_APP]: ["network.ingress"] }, + }); + await dep.start(); + try { + const base = await boundBaseUrl(dep); + expect((await fetch(`${base}/wh/cw_route1/${SECRET}`, { method: "POST" })).status).toBe(202); + await vi.waitFor(() => expect(runSync).toHaveBeenCalledTimes(1)); + + rows = []; // the service revoked the endpoint + await dep.rehydrate(); + expect((await fetch(`${base}/wh/cw_route1/${SECRET}`, { method: "POST" })).status).toBe(404); + expect(runSync).toHaveBeenCalledTimes(1); + } finally { + dep.stop(); + } + }); + + it("a mint mid-session binds the listener on rehydrate (first endpoint ever)", async () => { + let rows: readonly ConnectorWebhookRegistration[] = []; + const { dep, runSync } = deployment({ + endpoints: () => rows, + grants: { [CONNECTOR_APP]: ["network.ingress"] }, + }); + await dep.start(); + try { + expect((await dep.webhookInfo()).loopbackBaseUrl).toBeNull(); + + rows = [endpoint]; // the service minted the first endpoint + await dep.rehydrate(); + const base = await boundBaseUrl(dep); + expect((await fetch(`${base}/wh/cw_route1/${SECRET}`, { method: "POST" })).status).toBe(202); + await vi.waitFor(() => expect(runSync).toHaveBeenCalledWith("map-1")); + } finally { + dep.stop(); + } + }); +}); diff --git a/packages/shell/src/main/automations/webhook-dispatch.test.ts b/packages/shell/src/main/automations/webhook-dispatch.test.ts index c9de1137..41fc71fa 100644 --- a/packages/shell/src/main/automations/webhook-dispatch.test.ts +++ b/packages/shell/src/main/automations/webhook-dispatch.test.ts @@ -4,13 +4,15 @@ import { type ScheduleRegistration, type WebhookHit, type WebhookIngressPort, + type WebhookRoute, + WebhookTargetKind, type WebhookTrigger, } from "./automations-host"; /** A controllable in-memory ingress plane: capture registered routes + push * hits on demand. */ function fakeIngress() { - let routes: readonly WebhookTrigger[] = []; + let routes: readonly WebhookRoute[] = []; const listeners = new Set<(hit: WebhookHit) => void>(); const port: WebhookIngressPort = { register: (next) => { @@ -30,11 +32,18 @@ function fakeIngress() { }; } -function emptyRegistration(webhooks: WebhookTrigger[]): ScheduleRegistration { - return { workflows: [], reminders: [], entityEvents: [], webhooks }; +function emptyRegistration( + webhooks: WebhookTrigger[], + connectorWebhooks: ScheduleRegistration["connectorWebhooks"] = [], +): ScheduleRegistration { + return { workflows: [], reminders: [], entityEvents: [], webhooks, connectorWebhooks }; } -function makeHost(ingress: WebhookIngressPort, runWorkflow: ReturnType) { +function makeHost( + ingress: WebhookIngressPort, + runWorkflow: ReturnType, + connectorSync?: { runSync: (mappingId: string) => Promise }, +) { const host = new AutomationsHost({ scheduler: { tick: vi.fn(async () => []) } as never, reminderRunner: { fire: vi.fn() } as never, @@ -45,6 +54,7 @@ function makeHost(ingress: WebhookIngressPort, runWorkflow: ReturnType 0, webhookIngress: ingress, intervals: { set: () => 0 as never, clear: () => {} }, + ...(connectorSync ? { connectorSync } : {}), }); // Route runWorkflow through the spy. (host as unknown as { runWorkflow: unknown }).runWorkflow = runWorkflow; @@ -52,6 +62,24 @@ function makeHost(ingress: WebhookIngressPort, runWorkflow: ReturnType = {}): WebhookHit => ({ + targetKind: WebhookTargetKind.Workflow, + targetId: "wf1", + routeId: "r1", + method: "POST", + headers: {}, + bodyText: "", + ...over, +}); +const connectorHit = (over: Partial = {}): WebhookHit => ({ + targetKind: WebhookTargetKind.ConnectorSync, + targetId: "map1", + routeId: "cw1", + method: "POST", + headers: {}, + bodyText: "hostile-payload", + ...over, +}); describe("AutomationsHost webhook dispatch (11b.8)", () => { it("registers routes on hydrate and runs the bound workflow on a hit", async () => { @@ -60,16 +88,12 @@ describe("AutomationsHost webhook dispatch (11b.8)", () => { const host = makeHost(ingress.port, runWorkflow); await host.hydrate(emptyRegistration([route]), 0); - expect(ingress.routes()).toEqual([route]); + expect(ingress.routes()).toEqual([ + { routeId: "r1", targetKind: WebhookTargetKind.Workflow, targetId: "wf1", secret: "s1" }, + ]); host.start(); - ingress.push({ - workflowId: "wf1", - routeId: "r1", - method: "POST", - headers: { "x-a": "b" }, - bodyText: "hi", - }); + ingress.push(workflowHit({ headers: { "x-a": "b" }, bodyText: "hi" })); await vi.waitFor(() => expect(runWorkflow).toHaveBeenCalledTimes(1)); expect(runWorkflow).toHaveBeenCalledWith("wf1", "webhook:r1", { routeId: "r1", @@ -87,7 +111,7 @@ describe("AutomationsHost webhook dispatch (11b.8)", () => { await host.hydrate(emptyRegistration([route]), 0); host.start(); - ingress.push({ workflowId: "wfX", routeId: "gone", method: "POST", headers: {}, bodyText: "" }); + ingress.push(workflowHit({ targetId: "wfX", routeId: "gone" })); await new Promise((r) => setTimeout(r, 0)); expect(runWorkflow).not.toHaveBeenCalled(); host.stop(); @@ -101,8 +125,126 @@ describe("AutomationsHost webhook dispatch (11b.8)", () => { host.start(); host.stop(); - ingress.push({ workflowId: "wf1", routeId: "r1", method: "POST", headers: {}, bodyText: "" }); + ingress.push(workflowHit()); await new Promise((r) => setTimeout(r, 0)); expect(runWorkflow).not.toHaveBeenCalled(); }); }); + +describe("AutomationsHost connector webhook dispatch (Connector-6)", () => { + const connectorRegistration = [ + { mappingId: "map1", routeId: "cw1", secretSha256: "ab".repeat(32), connectorAppId: "io.x.gh" }, + ]; + + it("registers connector routes (hash custody) alongside workflow routes and syncs on a hit", async () => { + const ingress = fakeIngress(); + const runWorkflow = vi.fn(async () => null); + const runSync = vi.fn(async () => ({})); + const host = makeHost(ingress.port, runWorkflow, { runSync }); + + await host.hydrate(emptyRegistration([route], connectorRegistration), 0); + expect(ingress.routes()).toEqual([ + { routeId: "r1", targetKind: WebhookTargetKind.Workflow, targetId: "wf1", secret: "s1" }, + { + routeId: "cw1", + targetKind: WebhookTargetKind.ConnectorSync, + targetId: "map1", + secretSha256: "ab".repeat(32), + }, + ]); + host.start(); + + ingress.push(connectorHit()); + await vi.waitFor(() => expect(runSync).toHaveBeenCalledTimes(1)); + // Doorbell semantics: only the mapping id crosses — the payload never + // reaches the sync engine — and a connector hit never runs a workflow. + expect(runSync.mock.calls[0]).toEqual(["map1"]); + expect(runWorkflow).not.toHaveBeenCalled(); + host.stop(); + }); + + it("coalesces a hit storm: one in-flight sync + one trailing run", async () => { + const ingress = fakeIngress(); + // A holder, not a `let` — TS narrows a `let` assigned only inside a + // callback to `null` and the later call fails to typecheck. + const gate: { release: (() => void) | null } = { release: null }; + const runSync = vi.fn( + () => + new Promise((resolve) => { + gate.release = () => resolve({}); + }), + ); + const host = makeHost( + ingress.port, + vi.fn(async () => null), + { runSync }, + ); + await host.hydrate(emptyRegistration([], connectorRegistration), 0); + host.start(); + + ingress.push(connectorHit()); + await vi.waitFor(() => expect(runSync).toHaveBeenCalledTimes(1)); + // Storm while the first sync is in flight. + ingress.push(connectorHit()); + ingress.push(connectorHit()); + ingress.push(connectorHit()); + expect(runSync).toHaveBeenCalledTimes(1); + gate.release?.(); + // Exactly ONE trailing run for the whole storm. + await vi.waitFor(() => expect(runSync).toHaveBeenCalledTimes(2)); + gate.release?.(); + await new Promise((r) => setTimeout(r, 0)); + expect(runSync).toHaveBeenCalledTimes(2); + host.stop(); + }); + + it("drops a connector hit whose route is no longer registered (revoke race)", async () => { + const ingress = fakeIngress(); + const runSync = vi.fn(async () => ({})); + const host = makeHost( + ingress.port, + vi.fn(async () => null), + { runSync }, + ); + await host.hydrate(emptyRegistration([], connectorRegistration), 0); + // Revoke: re-hydrate without the endpoint. + await host.hydrate(emptyRegistration([], []), 0); + host.start(); + + ingress.push(connectorHit()); + await new Promise((r) => setTimeout(r, 0)); + expect(runSync).not.toHaveBeenCalled(); + host.stop(); + }); + + it("a sync failure drops the queued flag (next hit re-arms, no retry storm)", async () => { + const ingress = fakeIngress(); + const onError = vi.fn(); + const runSync = vi.fn(async () => { + throw new Error("provider down"); + }); + const host = new AutomationsHost({ + scheduler: { tick: vi.fn(async () => []) } as never, + reminderRunner: { fire: vi.fn() } as never, + loadWorkflow: vi.fn(), + makeInterpreterPorts: vi.fn(), + persistRun: vi.fn(), + appCapabilities: [], + clock: () => 0, + webhookIngress: ingress.port, + intervals: { set: () => 0 as never, clear: () => {} }, + connectorSync: { runSync }, + onError, + }); + await host.hydrate(emptyRegistration([], connectorRegistration), 0); + host.start(); + + ingress.push(connectorHit()); + await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + expect(runSync).toHaveBeenCalledTimes(1); + // A later hit dispatches again — the failed state was not left stuck. + ingress.push(connectorHit()); + await vi.waitFor(() => expect(runSync).toHaveBeenCalledTimes(2)); + host.stop(); + }); +}); diff --git a/packages/shell/src/main/automations/webhook-ingress-fanin.ts b/packages/shell/src/main/automations/webhook-ingress-fanin.ts index 792d9c79..f1eb273a 100644 --- a/packages/shell/src/main/automations/webhook-ingress-fanin.ts +++ b/packages/shell/src/main/automations/webhook-ingress-fanin.ts @@ -5,11 +5,11 @@ * plane can be absent (loopback-only, or relay-only under a headless host). */ -import type { WebhookHit, WebhookIngressPort, WebhookTrigger } from "./automations-host"; +import type { WebhookHit, WebhookIngressPort, WebhookRoute } from "./automations-host"; export function fanInWebhookPorts(ports: readonly WebhookIngressPort[]): WebhookIngressPort { return { - register(routes: readonly WebhookTrigger[]): void { + register(routes: readonly WebhookRoute[]): void { for (const port of ports) port.register(routes); }, subscribe(listener: (hit: WebhookHit) => void): () => void { diff --git a/packages/shell/src/main/automations/webhook-listener.test.ts b/packages/shell/src/main/automations/webhook-listener.test.ts index f7d3896c..3cbacdb5 100644 --- a/packages/shell/src/main/automations/webhook-listener.test.ts +++ b/packages/shell/src/main/automations/webhook-listener.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { WebhookHit, WebhookTrigger } from "./automations-host"; +import { sha256Hex } from "../storage/registry-repo/connector-webhooks-repo"; +import { type WebhookHit, type WebhookRoute, WebhookTargetKind } from "./automations-host"; import { WEBHOOK_MAX_BODY_BYTES, type WebhookLoopbackListener, @@ -8,14 +9,19 @@ import { describe("webhook loopback listener (11b.8)", () => { let listener: WebhookLoopbackListener | null = null; - const route: WebhookTrigger = { workflowId: "wf1", routeId: "r1", secret: "s3cr3t-token" }; + const route: WebhookRoute = { + routeId: "r1", + targetKind: WebhookTargetKind.Workflow, + targetId: "wf1", + secret: "s3cr3t-token", + }; afterEach(async () => { await listener?.close(); listener = null; }); - async function start(routes: WebhookTrigger[] = [route]): Promise { + async function start(routes: WebhookRoute[] = [route]): Promise { listener = createWebhookLoopbackListener(); const port = await listener.whenReady(); listener.register(routes); @@ -36,7 +42,8 @@ describe("webhook loopback listener (11b.8)", () => { await vi.waitFor(() => expect(hits.length).toBe(1)); expect(hits[0]).toMatchObject({ - workflowId: "wf1", + targetKind: WebhookTargetKind.Workflow, + targetId: "wf1", routeId: "r1", method: "POST", bodyText: "payload-body", @@ -78,11 +85,71 @@ describe("webhook loopback listener (11b.8)", () => { it("register replaces the active route set", async () => { const port = await start(); - listener?.register([{ workflowId: "wf2", routeId: "r2", secret: "s2" }]); + listener?.register([ + { routeId: "r2", targetKind: WebhookTargetKind.Workflow, targetId: "wf2", secret: "s2" }, + ]); // The old route is gone. expect( (await fetch(`http://127.0.0.1:${port}/wh/r1/s3cr3t-token`, { method: "POST" })).status, ).toBe(404); expect((await fetch(`http://127.0.0.1:${port}/wh/r2/s2`, { method: "POST" })).status).toBe(202); }); + + // ─── Connector-6: hash-custody connector routes ───────────────────────── + + const connectorSecret = "conn-s3cr3t-0123456789abcdefghij"; + const connectorRoute: WebhookRoute = { + routeId: "cw1", + targetKind: WebhookTargetKind.ConnectorSync, + targetId: "map1", + secretSha256: sha256Hex(connectorSecret), + }; + + it("authenticates a connector route against its SHA-256 digest and emits a connector-sync hit", async () => { + const port = await start([connectorRoute]); + const hits: WebhookHit[] = []; + listener?.subscribe((h) => hits.push(h)); + + const res = await fetch(`http://127.0.0.1:${port}/wh/cw1/${connectorSecret}`, { + method: "POST", + body: "ignored-doorbell-payload", + }); + expect(res.status).toBe(202); + await vi.waitFor(() => expect(hits.length).toBe(1)); + expect(hits[0]).toMatchObject({ + targetKind: WebhookTargetKind.ConnectorSync, + targetId: "map1", + routeId: "cw1", + }); + }); + + it("404s a wrong secret on a connector route (no oracle) — the digest itself never authenticates", async () => { + const port = await start([connectorRoute]); + const hits: WebhookHit[] = []; + listener?.subscribe((h) => hits.push(h)); + + expect((await fetch(`http://127.0.0.1:${port}/wh/cw1/wrong`, { method: "POST" })).status).toBe( + 404, + ); + // Presenting the stored digest as the secret must fail: it hashes to a + // different value (an attacker reading registry.db gains nothing). + expect( + ( + await fetch(`http://127.0.0.1:${port}/wh/cw1/${sha256Hex(connectorSecret)}`, { + method: "POST", + }) + ).status, + ).toBe(404); + expect(hits).toHaveLength(0); + }); + + it("fail-closed: a route with neither secret form authenticates nothing", async () => { + const port = await start([ + { routeId: "r9", targetKind: WebhookTargetKind.Workflow, targetId: "wf9" }, + ]); + expect((await fetch(`http://127.0.0.1:${port}/wh/r9/`, { method: "POST" })).status).toBe(404); + expect((await fetch(`http://127.0.0.1:${port}/wh/r9/undefined`, { method: "POST" })).status).toBe( + 404, + ); + }); }); diff --git a/packages/shell/src/main/automations/webhook-listener.ts b/packages/shell/src/main/automations/webhook-listener.ts index fc2b078f..479ddcd2 100644 --- a/packages/shell/src/main/automations/webhook-listener.ts +++ b/packages/shell/src/main/automations/webhook-listener.ts @@ -20,8 +20,8 @@ */ import { type IncomingMessage, type Server, type ServerResponse, createServer } from "node:http"; -import type { WebhookHit, WebhookIngressPort, WebhookTrigger } from "./automations-host"; -import { webhookSecretMatches } from "./webhook-secret"; +import type { WebhookHit, WebhookIngressPort, WebhookRoute } from "./automations-host"; +import { webhookRouteSecretMatches } from "./webhook-secret"; /** Inbound body cap — mirrors the block-frame 256 KiB posture. */ export const WEBHOOK_MAX_BODY_BYTES = 256 * 1024; @@ -87,7 +87,7 @@ function readCappedBody(req: IncomingMessage): Promise { export function createWebhookLoopbackListener( options: WebhookLoopbackOptions = {}, ): WebhookLoopbackListener { - const routes = new Map(); + const routes = new Map(); const listeners = new Set<(hit: WebhookHit) => void>(); let boundPort: number | null = null; @@ -99,7 +99,7 @@ export function createWebhookLoopbackListener( const parsed = PATH_RE.exec(new URL(req.url ?? "/", "http://127.0.0.1").pathname); const route = parsed ? routes.get(parsed[1] as string) : undefined; // Auth first — an unknown route and a wrong secret are indistinguishable. - if (!parsed || !route || !webhookSecretMatches(parsed[2] as string, route.secret)) { + if (!parsed || !route || !webhookRouteSecretMatches(parsed[2] as string, route)) { res.statusCode = 404; res.end(); return; @@ -120,7 +120,8 @@ export function createWebhookLoopbackListener( res.statusCode = 202; res.end(); emit({ - workflowId: route.workflowId, + targetKind: route.targetKind, + targetId: route.targetId, routeId: route.routeId, method: "POST", headers: flattenHeaders(req), @@ -163,7 +164,7 @@ export function createWebhookLoopbackListener( ready.catch(() => {}); return { - register(next: readonly WebhookTrigger[]): void { + register(next: readonly WebhookRoute[]): void { routes.clear(); for (const route of next) routes.set(route.routeId, route); }, diff --git a/packages/shell/src/main/automations/webhook-relay-port.test.ts b/packages/shell/src/main/automations/webhook-relay-port.test.ts index 3eba653e..1511fca9 100644 --- a/packages/shell/src/main/automations/webhook-relay-port.test.ts +++ b/packages/shell/src/main/automations/webhook-relay-port.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { WebhookHit, WebhookTrigger } from "./automations-host"; +import { type WebhookHit, type WebhookRoute, WebhookTargetKind } from "./automations-host"; import { type WebhookRelayInbound, type WebhookRelayTransport, @@ -29,13 +29,21 @@ function fakeTransport() { }; } -const route: WebhookTrigger = { workflowId: "wf1", routeId: "r1", secret: "s1" }; +const route: WebhookRoute = { + routeId: "r1", + targetKind: WebhookTargetKind.Workflow, + targetId: "wf1", + secret: "s1", +}; describe("webhook relay port (11b.8)", () => { it("tells the transport which routeIds to forward on register", () => { const t = fakeTransport(); const port = createWebhookRelayPort(t.transport); - port.register([route, { workflowId: "wf2", routeId: "r2", secret: "s2" }]); + port.register([ + route, + { routeId: "r2", targetKind: WebhookTargetKind.Workflow, targetId: "wf2", secret: "s2" }, + ]); expect(t.setRoutes).toHaveBeenCalledWith(["r1", "r2"]); }); @@ -48,7 +56,12 @@ describe("webhook relay port (11b.8)", () => { t.deliver({ routeId: "r1", secret: "s1", method: "POST", headers: { a: "b" }, bodyText: "x" }); expect(hits).toHaveLength(1); - expect(hits[0]).toMatchObject({ workflowId: "wf1", routeId: "r1", bodyText: "x" }); + expect(hits[0]).toMatchObject({ + targetKind: WebhookTargetKind.Workflow, + targetId: "wf1", + routeId: "r1", + bodyText: "x", + }); expect((hits[0] as unknown as { secret?: string }).secret).toBeUndefined(); }); diff --git a/packages/shell/src/main/automations/webhook-relay-port.ts b/packages/shell/src/main/automations/webhook-relay-port.ts index 21c1b296..c0291e83 100644 --- a/packages/shell/src/main/automations/webhook-relay-port.ts +++ b/packages/shell/src/main/automations/webhook-relay-port.ts @@ -20,8 +20,8 @@ * and cannot mint a hit for a route/secret it doesn't already carry. */ -import type { WebhookHit, WebhookIngressPort, WebhookTrigger } from "./automations-host"; -import { webhookSecretMatches } from "./webhook-secret"; +import type { WebhookHit, WebhookIngressPort, WebhookRoute } from "./automations-host"; +import { webhookRouteSecretMatches } from "./webhook-secret"; /** One request the relay forwarded to the desktop. `secret` is the value from * the inbound URL — re-verified here, never trusted from the relay. */ @@ -50,16 +50,17 @@ export type WebhookRelayPort = WebhookIngressPort & { close(): void }; * uniformly (via `fanInWebhookPorts`). */ export function createWebhookRelayPort(transport: WebhookRelayTransport): WebhookRelayPort { - const routes = new Map(); + const routes = new Map(); const listeners = new Set<(hit: WebhookHit) => void>(); const unsubscribeTransport = transport.onInbound((msg) => { const route = routes.get(msg.routeId); // Re-verify against the registered route — the relay is not trusted to // have authenticated. Unknown route or bad secret ⇒ drop silently. - if (!route || !webhookSecretMatches(msg.secret, route.secret)) return; + if (!route || !webhookRouteSecretMatches(msg.secret, route)) return; const hit: WebhookHit = { - workflowId: route.workflowId, + targetKind: route.targetKind, + targetId: route.targetId, routeId: route.routeId, method: msg.method, headers: msg.headers, @@ -69,7 +70,7 @@ export function createWebhookRelayPort(transport: WebhookRelayTransport): Webhoo }); return { - register(next: readonly WebhookTrigger[]): void { + register(next: readonly WebhookRoute[]): void { routes.clear(); for (const route of next) routes.set(route.routeId, route); transport.setRoutes([...routes.keys()]); diff --git a/packages/shell/src/main/automations/webhook-secret.ts b/packages/shell/src/main/automations/webhook-secret.ts index 2374d4e9..8adecadd 100644 --- a/packages/shell/src/main/automations/webhook-secret.ts +++ b/packages/shell/src/main/automations/webhook-secret.ts @@ -5,7 +5,8 @@ * oracle would let a local process brute-force the secret. */ -import { timingSafeEqual } from "node:crypto"; +import { createHash, timingSafeEqual } from "node:crypto"; +import type { WebhookRoute } from "./automations-host"; /** True iff `provided` equals `expected`, compared in constant time. A length * mismatch returns false immediately (timingSafeEqual requires equal-length @@ -17,3 +18,24 @@ export function webhookSecretMatches(provided: string, expected: string): boolea if (a.length !== b.length) return false; return timingSafeEqual(a, b); } + +/** Connector-6 — verify `provided` against a hex SHA-256 digest (hash-only + * at-rest custody: registry.db never holds the plaintext, so an offline read + * of the DB cannot recover a live endpoint secret). Hashing the presented + * value first means the comparison runs over fixed-length digests — + * constant-time by construction. */ +export function webhookSecretMatchesSha256(provided: string, expectedHex: string): boolean { + const digest = createHash("sha256").update(provided, "utf8").digest(); + const expected = Buffer.from(expectedHex, "hex"); + if (expected.length !== digest.length) return false; + return timingSafeEqual(digest, expected); +} + +/** Verify a presented secret against a route's custody form. A route with + * neither verifier authenticates nothing — fail-closed. */ +export function webhookRouteSecretMatches(provided: string, route: WebhookRoute): boolean { + if (route.secret !== undefined) return webhookSecretMatches(provided, route.secret); + if (route.secretSha256 !== undefined) + return webhookSecretMatchesSha256(provided, route.secretSha256); + return false; +} diff --git a/packages/shell/src/main/automations/wiring.ts b/packages/shell/src/main/automations/wiring.ts index eba066c6..48689803 100644 --- a/packages/shell/src/main/automations/wiring.ts +++ b/packages/shell/src/main/automations/wiring.ts @@ -47,6 +47,8 @@ import { } from "./automation-host-designation"; import { AutomationsHost, + type ConnectorSyncPort, + type ConnectorWebhookRegistration, type EntityChangeSource, type FileWatchPort, type IntervalFactory, @@ -122,6 +124,14 @@ export type AutomationsWiringDeps = { * Constructed in `index.ts` where those live; absent keeps file-watch * triggers registered but never firing (tests / headless). */ fileWatch?: FileWatchPort; + /** Connector-6 — the connector sync engine an authenticated connector + * webhook hit dispatches to (`connectors.sync(mappingId)`). Absent keeps + * connector webhook routes registered but never firing. */ + connectorSync?: ConnectorSyncPort; + /** Connector-6 — the registry-backed connector webhook endpoints + * (shell-internal, hash-only digests). Each is gated per connector app + * on `network.ingress` at hydrate — fail-closed. */ + listConnectorWebhooks?: () => Promise; clock?: () => number; intervalMs?: number; intervals?: IntervalFactory; @@ -230,6 +240,11 @@ export function buildAutomationsDeployment(deps: AutomationsWiringDeps): Automat entityChanges: deps.entityChanges, onError, ...(deps.fileWatch ? { fileWatch: deps.fileWatch } : {}), + ...(deps.connectorSync ? { connectorSync: deps.connectorSync } : {}), + // Connector-6 — per-hit live grant re-check (defined below; only ever + // invoked at dispatch time, so the TDZ never bites). + connectorWebhookAllowed: (mappingId) => + grantedConnectorWebhooks().then((rows) => rows.some((r) => r.mappingId === mappingId)), ...(deps.postAlert ? { postAlert: deps.postAlert } : {}), ...(deps.intervalMs !== undefined ? { intervalMs: deps.intervalMs } : {}), ...(deps.intervals ? { intervals: deps.intervals } : {}), @@ -259,8 +274,48 @@ export function buildAutomationsDeployment(deps: AutomationsWiringDeps): Automat const hasIngressGrant = async (): Promise => (await appGrantCeiling(deps.getLedger)).includes(NETWORK_INGRESS_CAP); + // Connector-6 — the registry endpoints, gated per CONNECTOR app on its own + // `network.ingress` grant (never the automations app's). A missing/failing + // ledger or store reads as no routes — fail-closed, nothing registers. + const grantedConnectorWebhooks = async (): Promise => { + if (!deps.listConnectorWebhooks) return []; + let rows: readonly ConnectorWebhookRegistration[]; + try { + rows = await deps.listConnectorWebhooks(); + } catch (error) { + onError("connector webhook list", error); + return []; + } + if (rows.length === 0) return []; + let ledger: Awaited> = null; + try { + ledger = await deps.getLedger(); + } catch { + return []; + } + if (!ledger) return []; + const grantedByApp = new Map(); + const out: ConnectorWebhookRegistration[] = []; + for (const row of rows) { + let granted = grantedByApp.get(row.connectorAppId); + if (granted === undefined) { + try { + granted = ledger.has(row.connectorAppId, NETWORK_INGRESS_CAP); + } catch { + granted = false; + } + grantedByApp.set(row.connectorAppId, granted); + } + if (granted) out.push(row); + } + return out; + }; + const ensureWebhookIngress = async (): Promise => { - if (webhookListener || !(await hasIngressGrant())) return; + if (webhookListener) return; + // Bind when EITHER plane needs ingress: the automations app's own grant, + // or at least one connector endpoint whose app holds the grant. + if (!(await hasIngressGrant()) && (await grantedConnectorWebhooks()).length === 0) return; const preferredPort = (await deps.webhookPortStore?.get().catch(() => null)) ?? undefined; const listener = createWebhookLoopbackListener( preferredPort !== undefined ? { preferredPort } : {}, @@ -301,6 +356,9 @@ export function buildAutomationsDeployment(deps: AutomationsWiringDeps): Automat // if a listener is somehow live — a revoke takes effect on the next // re-derive (routes emptied), the socket goes inert (404s everything). if (!(await hasIngressGrant())) registration.webhooks = []; + // Connector-6 — connector endpoints ride the same route table, each + // already filtered on ITS app's `network.ingress` grant (fail-closed). + registration.connectorWebhooks = await grantedConnectorWebhooks(); // 9.14.9b — task due/scheduled + event alerts ride the same schedule. // 0.3.1 — register alerts whose instant is `> lastRun` (the scheduler's // persisted watermark), not just `> now`: a reminder that came due while @@ -425,7 +483,12 @@ export function buildAutomationsDeployment(deps: AutomationsWiringDeps): Automat runNow: (workflowId) => host.runNow(workflowId), // 11b.10 — re-derive now (e.g. a Settings file-watch revoke: the grant is // gone, so hydrate re-registers the port without the dropped watch). - rehydrate: () => hydrateFromEntities(), + // Connector-6 — a webhook mint mid-session may be the FIRST route, so + // bind the (lazily-created) listener before the routes register. + rehydrate: async () => { + if (scheduling && !stopped) await ensureWebhookIngress(); + await hydrateFromEntities(); + }, async webhookInfo(): Promise { const port = webhookListener?.port() ?? null; return { diff --git a/packages/shell/src/main/connectors/connector-webhook-attack.test.ts b/packages/shell/src/main/connectors/connector-webhook-attack.test.ts new file mode 100644 index 00000000..b601d8f4 --- /dev/null +++ b/packages/shell/src/main/connectors/connector-webhook-attack.test.ts @@ -0,0 +1,198 @@ +/** + * Connector-6 — ADVERSARIAL probes against the `connectors.webhook*` broker + * surface, kept as regression tests: + * - owner pinning across all three methods, including the ordering that + * stops a non-owner reaching the store at all, + * - the oracle-free denial (an unknown mapping and someone else's mapping + * are indistinguishable to the caller), + * - fail-closed behaviour when the ledger throws, + * - argument shapes (non-object, prototype pollution, non-string ref). + */ + +import { LedgerUnavailableError } from "@brainstorm-os/capabilities/ledger"; +import type { CapabilityLedger } from "@brainstorm-os/capabilities/ledger"; +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedAccount, ResolvedConnector } from "./connectors-request"; +import { type ConnectorsServiceDeps, makeConnectorsServiceHandler } from "./connectors-service"; +import type { OAuthBroker } from "./oauth-broker"; + +const OWNER = "io.brainstorm.github-issues"; +const ATTACKER = "io.evil.sibling"; + +const resolvedConnector: ResolvedConnector = { + connectorAppId: OWNER, + provider: { + authorizeUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + clientId: "abc", + scopes: ["repo"], + egressOrigins: ["https://api.github.com"], + }, + apiBaseUrl: "https://api.github.com", +}; +const resolvedAccount: ResolvedAccount = { + ...resolvedConnector, + accountId: "account-1", + connectorRef: "connector-1", +}; + +const envelope = (method: string, arg: unknown, app = OWNER) => ({ + v: 1 as const, + msg: "m", + app, + service: "connectors", + method, + args: [arg], + caps: [], +}); + +const allCaps = { has: () => true } as unknown as CapabilityLedger; + +function harness(over: Partial = {}) { + const mint = vi.fn(() => ({ routeId: "cw_r1", secret: "plain-secret" })); + const getByMapping = vi.fn(() => ({ routeId: "cw_r1", createdAt: 1000 })); + const revokeByMapping = vi.fn(() => true); + const revokeByAccount = vi.fn(() => 0); + const store = { mint, getByMapping, revokeByMapping, revokeByAccount }; + const deps: ConnectorsServiceDeps = { + broker: { + authorize: vi.fn(), + connectWithToken: vi.fn(), + revoke: vi.fn(), + } as unknown as OAuthBroker, + redirectProvider: { start: () => Promise.reject(new Error("unused")) }, + resolveConnector: () => Promise.resolve(resolvedConnector), + resolveAccount: () => Promise.resolve(resolvedAccount), + request: vi.fn(), + getLedger: () => Promise.resolve(allCaps), + resolveMappingOwner: async (ref: string) => + ref === "map-1" ? { accountId: "account-1", connectorAppId: OWNER } : null, + getWebhookStore: async () => store, + ingressInfo: async () => ({ loopbackBaseUrl: "http://127.0.0.1:4242", relayBaseUrl: null }), + onWebhooksChanged: vi.fn(async () => {}), + ...over, + }; + return { handler: makeConnectorsServiceHandler(deps), mint, getByMapping, revokeByMapping }; +} + +const METHODS = ["webhookRegister", "webhookStatus", "webhookRevoke"] as const; + +describe("Connector-6 attack — connectors.webhook* broker surface", () => { + it("a sibling app holding EVERY capability still cannot touch a mapping it does not own", async () => { + const { handler, mint, getByMapping, revokeByMapping } = harness(); + for (const method of METHODS) { + await expect(handler(envelope(method, { mappingRef: "map-1" }, ATTACKER))).rejects.toMatchObject( + { name: "Denied" }, + ); + } + // The store is never reached — not even a read. + expect(mint).not.toHaveBeenCalled(); + expect(getByMapping).not.toHaveBeenCalled(); + expect(revokeByMapping).not.toHaveBeenCalled(); + }); + + it("an unknown mapping and someone else's mapping are indistinguishable (no existence oracle)", async () => { + const { handler } = harness(); + const failure = async (method: string, ref: string) => + await Promise.resolve(handler(envelope(method, { mappingRef: ref }, ATTACKER))).then( + () => ({ name: "resolved", message: "" }), + (e: Error) => ({ name: e.name, message: e.message }), + ); + for (const method of METHODS) { + const notOwned = await failure(method, "map-1"); + const unknown = await failure(method, "no-such"); + expect(notOwned).toEqual({ + name: "Denied", + message: `connectors.${method}: ${ATTACKER} has no webhook surface for map-1`, + }); + // Same name, same sentence — only the echoed ref differs. + expect(unknown.name).toBe(notOwned.name); + expect(unknown.message.replace("no-such", "map-1")).toBe(notOwned.message); + } + }); + + it("fail-closed: a ledger that throws yields Unavailable, never approval", async () => { + const { handler, mint } = harness({ + getLedger: () => Promise.reject(new LedgerUnavailableError("locked")), + }); + for (const method of METHODS) { + await expect(handler(envelope(method, { mappingRef: "map-1" }))).rejects.toMatchObject({ + name: "Unavailable", + }); + } + expect(mint).not.toHaveBeenCalled(); + }); + + it("fail-closed: `has` throwing mid-check yields Unavailable, never approval", async () => { + const { handler, mint } = harness({ + getLedger: () => + Promise.resolve({ + has: () => { + throw new LedgerUnavailableError("locked"); + }, + } as unknown as CapabilityLedger), + }); + await expect(handler(envelope("webhookRegister", { mappingRef: "map-1" }))).rejects.toMatchObject( + { name: "Unavailable" }, + ); + expect(mint).not.toHaveBeenCalled(); + }); + + it("the runtime network.ingress gate is checked on the RESOLVED owner, not a caller claim", async () => { + // Owner holds `connectors.webhook` but not `network.ingress`. + const { handler, mint } = harness({ + getLedger: () => + Promise.resolve({ + has: (_app: string, cap: string) => cap === "connectors.webhook", + } as unknown as CapabilityLedger), + }); + await expect(handler(envelope("webhookRegister", { mappingRef: "map-1" }))).rejects.toMatchObject( + { name: "Denied" }, + ); + expect(mint).not.toHaveBeenCalled(); + // status / revoke deliberately do NOT require the ingress grant — the + // user must still be able to SEE and KILL an endpoint after revoking it. + await expect(handler(envelope("webhookStatus", { mappingRef: "map-1" }))).resolves.toMatchObject({ + registered: true, + }); + await expect(handler(envelope("webhookRevoke", { mappingRef: "map-1" }))).resolves.toMatchObject({ + ok: true, + }); + }); + + it("rejects hostile argument shapes without reaching the store", async () => { + const { handler, mint, getByMapping, revokeByMapping } = harness(); + const bad: unknown[] = [ + null, + undefined, + "map-1", + 42, + ["map-1"], + {}, + { mappingRef: "" }, + { mappingRef: 1 }, + { mappingRef: { toString: () => "map-1" } }, + JSON.parse('{"__proto__":{"mappingRef":"map-1"}}'), + ]; + for (const method of METHODS) { + for (const arg of bad) { + await expect(handler(envelope(method, arg))).rejects.toMatchObject({ name: "Invalid" }); + } + } + expect(mint).not.toHaveBeenCalled(); + expect(getByMapping).not.toHaveBeenCalled(); + expect(revokeByMapping).not.toHaveBeenCalled(); + // And nothing polluted Object.prototype along the way. + expect(({} as { mappingRef?: string }).mappingRef).toBeUndefined(); + }); + + it("no response from the surface carries the secret except the one-time register", async () => { + const { handler } = harness(); + const registered = await handler(envelope("webhookRegister", { mappingRef: "map-1" })); + expect(JSON.stringify(registered)).toContain("plain-secret"); + for (const method of ["webhookStatus", "webhookRevoke"]) { + const result = await handler(envelope(method, { mappingRef: "map-1" })); + expect(JSON.stringify(result)).not.toContain("plain-secret"); + } + }); +}); diff --git a/packages/shell/src/main/connectors/connectors-service.test.ts b/packages/shell/src/main/connectors/connectors-service.test.ts index f67eb21f..ca3b8852 100644 --- a/packages/shell/src/main/connectors/connectors-service.test.ts +++ b/packages/shell/src/main/connectors/connectors-service.test.ts @@ -137,3 +137,143 @@ describe("connectors service handler", () => { await expect(handler(envelope("authorize", null))).rejects.toMatchObject({ name: "Invalid" }); }); }); + +// ─── Connector-6: webhook endpoint surface ────────────────────────────────── + +function ledgerWithCaps(caps: string[]): CapabilityLedger { + return { has: (_app: string, cap: string) => caps.includes(cap) } as unknown as CapabilityLedger; +} + +function makeWebhookDeps(over: Partial = {}) { + const mint = vi.fn(() => ({ routeId: "cw_r1", secret: "plain-secret" })); + const getByMapping = vi.fn(() => ({ routeId: "cw_r1", createdAt: 1000 })); + const revokeByMapping = vi.fn(() => true); + const revokeByAccount = vi.fn(() => 2); + const store = { mint, getByMapping, revokeByMapping, revokeByAccount }; + const onWebhooksChanged = vi.fn(async () => {}); + const { deps } = makeDeps({ + getLedger: () => + Promise.resolve(ledgerWithCaps(["connectors.webhook", "connectors.oauth", "network.ingress"])), + resolveMappingOwner: async (mappingRef: string) => + mappingRef === "map-1" + ? { accountId: "account-1", connectorAppId: "io.brainstorm.github-issues" } + : null, + getWebhookStore: async () => store, + ingressInfo: async () => ({ + loopbackBaseUrl: "http://127.0.0.1:4242", + relayBaseUrl: null, + }), + onWebhooksChanged, + ...over, + }); + return { deps, mint, getByMapping, revokeByMapping, revokeByAccount, onWebhooksChanged }; +} + +describe("connectors service — webhook endpoints (Connector-6)", () => { + it("webhookRegister mints, re-registers routes, and reveals the secret exactly once (in the URL)", async () => { + const { deps, mint, onWebhooksChanged } = makeWebhookDeps(); + const handler = makeConnectorsServiceHandler(deps); + const result = (await handler(envelope("webhookRegister", { mappingRef: "map-1" }))) as { + routeId: string; + endpointPath: string; + loopbackUrl: string | null; + relayUrl: string | null; + }; + expect(mint).toHaveBeenCalledWith({ + mappingId: "map-1", + accountId: "account-1", + connectorAppId: "io.brainstorm.github-issues", + }); + expect(onWebhooksChanged).toHaveBeenCalledOnce(); + expect(result).toEqual({ + routeId: "cw_r1", + endpointPath: "/wh/cw_r1/plain-secret", + loopbackUrl: "http://127.0.0.1:4242/wh/cw_r1/plain-secret", + relayUrl: null, + }); + }); + + it("denies webhookRegister without connectors.webhook", async () => { + const { deps } = makeWebhookDeps({ + getLedger: () => Promise.resolve(ledgerWithCaps(["connectors.oauth", "network.ingress"])), + }); + const handler = makeConnectorsServiceHandler(deps); + await expect(handler(envelope("webhookRegister", { mappingRef: "map-1" }))).rejects.toMatchObject( + { name: "Denied" }, + ); + }); + + it("denies webhookRegister without the runtime network.ingress grant (fail-closed)", async () => { + const { deps, mint } = makeWebhookDeps({ + getLedger: () => Promise.resolve(ledgerWithCaps(["connectors.webhook"])), + }); + const handler = makeConnectorsServiceHandler(deps); + await expect(handler(envelope("webhookRegister", { mappingRef: "map-1" }))).rejects.toMatchObject( + { name: "Denied" }, + ); + expect(mint).not.toHaveBeenCalled(); + }); + + it("denies the webhook surface to an app that does not own the mapping", async () => { + const { deps, mint, revokeByMapping } = makeWebhookDeps(); + const handler = makeConnectorsServiceHandler(deps); + for (const method of ["webhookRegister", "webhookStatus", "webhookRevoke"]) { + await expect( + handler(envelope(method, { mappingRef: "map-1" }, "io.evil.sibling")), + ).rejects.toMatchObject({ name: "Denied" }); + } + expect(mint).not.toHaveBeenCalled(); + expect(revokeByMapping).not.toHaveBeenCalled(); + }); + + it("webhookRegister rejects an unknown mapping (server-side resolve, never the caller's claim)", async () => { + const { deps } = makeWebhookDeps(); + const handler = makeConnectorsServiceHandler(deps); + // `Denied`, not `Invalid` — an unknown mapping and someone else's + // mapping must be indistinguishable (no cross-app existence oracle). + await expect(handler(envelope("webhookRegister", { mappingRef: "nope" }))).rejects.toMatchObject({ + name: "Denied", + }); + }); + + it("webhookStatus never returns the secret", async () => { + const { deps } = makeWebhookDeps(); + const handler = makeConnectorsServiceHandler(deps); + const result = await handler(envelope("webhookStatus", { mappingRef: "map-1" })); + expect(result).toEqual({ + registered: true, + routeId: "cw_r1", + createdAt: 1000, + loopbackBaseUrl: "http://127.0.0.1:4242", + relayBaseUrl: null, + }); + expect(JSON.stringify(result)).not.toContain("plain-secret"); + }); + + it("webhookRevoke kills the endpoint and re-derives the route table", async () => { + const { deps, revokeByMapping, onWebhooksChanged } = makeWebhookDeps(); + const handler = makeConnectorsServiceHandler(deps); + await expect(handler(envelope("webhookRevoke", { mappingRef: "map-1" }))).resolves.toEqual({ + ok: true, + removed: true, + }); + expect(revokeByMapping).toHaveBeenCalledWith("map-1"); + expect(onWebhooksChanged).toHaveBeenCalledOnce(); + }); + + it("account revoke cascades: every endpoint under the account dies with it", async () => { + const { deps, revokeByAccount, onWebhooksChanged } = makeWebhookDeps(); + const handler = makeConnectorsServiceHandler(deps); + await handler(envelope("revoke", { accountId: "account-1" })); + expect(revokeByAccount).toHaveBeenCalledWith("account-1"); + expect(onWebhooksChanged).toHaveBeenCalledOnce(); + }); + + it("webhookRegister is Unavailable without a vault session (no store)", async () => { + const { deps } = makeWebhookDeps({ getWebhookStore: async () => null }); + const handler = makeConnectorsServiceHandler(deps); + await expect(handler(envelope("webhookRegister", { mappingRef: "map-1" }))).rejects.toMatchObject( + { name: "Unavailable" }, + ); + }); +}); diff --git a/packages/shell/src/main/connectors/connectors-service.ts b/packages/shell/src/main/connectors/connectors-service.ts index ee2bfc3f..e08c2610 100644 --- a/packages/shell/src/main/connectors/connectors-service.ts +++ b/packages/shell/src/main/connectors/connectors-service.ts @@ -27,6 +27,30 @@ import type { RedirectProvider } from "./oauth-redirect"; export const CONNECTORS_OAUTH_CAP = "connectors.oauth"; export const CONNECTORS_REQUEST_CAP = "connectors.request"; +/** Connector-6 — gates the webhook endpoint surface (register / status / + * revoke). Registration ADDITIONALLY requires the mapping's connector app to + * hold the runtime `network.ingress` grant — fail-closed. */ +export const CONNECTORS_WEBHOOK_CAP = "connectors.webhook"; +export const NETWORK_INGRESS_CAP = "network.ingress"; + +/** Connector-6 — the registry-backed endpoint store (satisfied by + * `ConnectorWebhooksRepository`; a port so this module stays Electron-free + * and unit-testable). `mint` is the ONLY reveal of a plaintext secret. */ +export type ConnectorWebhookStore = { + mint(input: { mappingId: string; accountId: string; connectorAppId: string }): { + routeId: string; + secret: string; + }; + getByMapping(mappingId: string): { routeId: string; createdAt: number } | null; + revokeByMapping(mappingId: string): boolean; + revokeByAccount(accountId: string): number; +}; + +/** The ingress endpoint bases the running deployment exposes (11b.8). */ +export type ConnectorIngressInfo = { + loopbackBaseUrl: string | null; + relayBaseUrl: string | null; +}; export type ConnectorsServiceDeps = { broker: OAuthBroker; @@ -39,6 +63,18 @@ export type ConnectorsServiceDeps = { request: ConnectorRequestFn; /** Connector-4 — run a mapping's pull now (manual "Sync now" / scheduler). */ sync?: (mappingRef: string) => Promise; + /** Connector-6 — resolve a `SyncMapping` to its owning account + connector + * app, SERVER-SIDE (never trusted from the caller). */ + resolveMappingOwner?: ( + mappingRef: string, + ) => Promise<{ accountId: string; connectorAppId: string } | null>; + /** Connector-6 — per-session endpoint store (null without a vault). */ + getWebhookStore?: () => Promise; + /** Connector-6 — the live ingress bases for composing endpoint URLs. */ + ingressInfo?: () => Promise; + /** Connector-6 — endpoints changed (mint/rotate/revoke): re-derive the + * ingress route table (the automations deployment's rehydrate). */ + onWebhooksChanged?: () => Promise; /** Server-side capability source; omit only in unit tests that presume * the caller is authorized (mirrors the network handler). */ getLedger?: () => Promise; @@ -119,6 +155,12 @@ export function makeConnectorsServiceHandler(deps: ConnectorsServiceDeps): Servi return await handleRequest(envelope, deps); case "sync": return await handleSync(envelope, deps); + case "webhookRegister": + return await handleWebhookRegister(envelope, deps); + case "webhookStatus": + return await handleWebhookStatus(envelope, deps); + case "webhookRevoke": + return await handleWebhookRevoke(envelope, deps); default: throw makeError("Invalid", `unknown connectors method: ${envelope.method}`); } @@ -192,9 +234,132 @@ async function handleRevoke( const resolved = await deps.resolveAccount(accountId); if (!resolved) throw makeError("Invalid", `connectors.revoke: unknown account ${accountId}`); await deps.broker.revoke({ connectorAppId: resolved.connectorAppId, accountId }); + // Connector-6 — endpoints die with the account (doc 56 §Trust: disconnect + // is never a silent half-state). Route-table re-derive drops them live. + const store = await deps.getWebhookStore?.(); + if (store && store.revokeByAccount(accountId) > 0) await deps.onWebhooksChanged?.(); return { ok: true }; } +/** Connector-6 — the mapping's owner, resolved server-side, with the caller's + * identity pinned to it: only the mapping's own connector app may manage its + * endpoint (defense-in-depth over the capability gate — a sibling app with + * `connectors.webhook` cannot mint/see/kill another connector's endpoint). */ +async function requireMappingOwner( + envelope: Envelope, + deps: ConnectorsServiceDeps, + mappingRef: string, +): Promise<{ accountId: string; connectorAppId: string }> { + if (!deps.resolveMappingOwner) { + throw makeError("Unavailable", `connectors.${envelope.method}: webhook surface not wired`); + } + const owner = await deps.resolveMappingOwner(mappingRef); + // A mapping that does not exist and one owned by ANOTHER app produce the + // identical error: distinguishing them let any app holding + // `connectors.webhook` probe which mapping ids exist in the vault (the + // same oracle-free posture the ingress plane's uniform 404 takes). + if (!owner || owner.connectorAppId !== envelope.app) { + throw makeError( + "Denied", + `connectors.${envelope.method}: ${envelope.app} has no webhook surface for ${mappingRef}`, + ); + } + return owner; +} + +async function requireWebhookStore( + envelope: Envelope, + deps: ConnectorsServiceDeps, +): Promise { + const store = await deps.getWebhookStore?.(); + if (!store) { + throw makeError("Unavailable", `connectors.${envelope.method}: no active vault session`); + } + return store; +} + +async function ingressInfo(deps: ConnectorsServiceDeps): Promise { + return (await deps.ingressInfo?.()) ?? { loopbackBaseUrl: null, relayBaseUrl: null }; +} + +async function handleWebhookRegister( + envelope: Envelope, + deps: ConnectorsServiceDeps, +): Promise<{ + routeId: string; + endpointPath: string; + loopbackUrl: string | null; + relayUrl: string | null; +}> { + await requireCapability(envelope, deps, CONNECTORS_WEBHOOK_CAP); + const arg = objectArg(envelope); + const mappingRef = requireString(arg.mappingRef, "mappingRef", "webhookRegister"); + const owner = await requireMappingOwner(envelope, deps, mappingRef); + // The runtime `network.ingress` grant on the CONNECTOR app is the ingress + // gate (11b.8 model) — no grant, no endpoint. Fail-closed. + await requireServiceCapability(envelope, deps.getLedger, NETWORK_INGRESS_CAP, "connectors"); + const store = await requireWebhookStore(envelope, deps); + const minted = store.mint({ + mappingId: mappingRef, + accountId: owner.accountId, + connectorAppId: owner.connectorAppId, + }); + // Register the route (and lazily bind the listener) BEFORE returning the + // URL, so the endpoint is live the moment the caller can see it. + await deps.onWebhooksChanged?.(); + const info = await ingressInfo(deps); + // Reveal-once: the plaintext secret exists only inside this response's + // path/URLs — at rest the store holds its SHA-256 and no later method + // (status included) can return it again. + const endpointPath = `/wh/${minted.routeId}/${minted.secret}`; + return { + routeId: minted.routeId, + endpointPath, + loopbackUrl: info.loopbackBaseUrl ? `${info.loopbackBaseUrl}${endpointPath}` : null, + relayUrl: info.relayBaseUrl ? `${info.relayBaseUrl}${endpointPath}` : null, + }; +} + +async function handleWebhookStatus( + envelope: Envelope, + deps: ConnectorsServiceDeps, +): Promise<{ + registered: boolean; + routeId: string | null; + createdAt: number | null; + loopbackBaseUrl: string | null; + relayBaseUrl: string | null; +}> { + await requireCapability(envelope, deps, CONNECTORS_WEBHOOK_CAP); + const arg = objectArg(envelope); + const mappingRef = requireString(arg.mappingRef, "mappingRef", "webhookStatus"); + await requireMappingOwner(envelope, deps, mappingRef); + const store = await requireWebhookStore(envelope, deps); + const record = store.getByMapping(mappingRef); + const info = await ingressInfo(deps); + return { + registered: record !== null, + routeId: record?.routeId ?? null, + createdAt: record?.createdAt ?? null, + loopbackBaseUrl: info.loopbackBaseUrl, + relayBaseUrl: info.relayBaseUrl, + }; +} + +async function handleWebhookRevoke( + envelope: Envelope, + deps: ConnectorsServiceDeps, +): Promise<{ ok: true; removed: boolean }> { + await requireCapability(envelope, deps, CONNECTORS_WEBHOOK_CAP); + const arg = objectArg(envelope); + const mappingRef = requireString(arg.mappingRef, "mappingRef", "webhookRevoke"); + await requireMappingOwner(envelope, deps, mappingRef); + const store = await requireWebhookStore(envelope, deps); + const removed = store.revokeByMapping(mappingRef); + if (removed) await deps.onWebhooksChanged?.(); + return { ok: true, removed }; +} + async function handleRequest(envelope: Envelope, deps: ConnectorsServiceDeps): Promise { await requireCapability(envelope, deps, CONNECTORS_REQUEST_CAP); const arg = objectArg(envelope); diff --git a/packages/shell/src/main/connectors/wiring.ts b/packages/shell/src/main/connectors/wiring.ts index 7fdf5dc6..12974ce2 100644 --- a/packages/shell/src/main/connectors/wiring.ts +++ b/packages/shell/src/main/connectors/wiring.ts @@ -19,6 +19,7 @@ import { CONNECTOR_ACCOUNT_TYPE_URL, ConflictPolicy, type ConnectorAccountDef, + SYNC_MAPPING_TYPE_URL, SYNC_RUN_TYPE_URL, SyncDirection, } from "@brainstorm-os/sdk-types"; @@ -30,7 +31,11 @@ import { type ResolvedConnector, makeConnectorRequest, } from "./connectors-request"; -import type { ConnectorsServiceDeps } from "./connectors-service"; +import type { + ConnectorIngressInfo, + ConnectorWebhookStore, + ConnectorsServiceDeps, +} from "./connectors-service"; import { type ConnectorsSync, type SyncContext, @@ -61,6 +66,13 @@ export type ConnectorsWiringDeps = { openExternal: (url: string) => Promise; notify: (n: { title: string; body: string }) => void; onRefused?: (info: { app: string; url: string; reason: string }) => void; + /** Connector-6 — per-session registry-backed webhook endpoint store. */ + getWebhookStore?: () => Promise; + /** Connector-6 — the live ingress bases (the automations deployment's + * `webhookInfo`, thunked — the deployment is per-session). */ + ingressInfo?: () => Promise; + /** Connector-6 — endpoints changed: re-derive the ingress route table. */ + onWebhooksChanged?: () => Promise; }; /** A `CredentialStore`-backed Tier-2 token store (JSON value under the @@ -287,6 +299,22 @@ export function buildConnectorsServiceDeps(deps: ConnectorsWiringDeps): Connecto resolveAccount, request: connectorRequest, sync: (mappingRef: string) => sync.runSync(mappingRef), + // Connector-6 — owner resolution rides the same server-side mapping + // resolve as the sync engine (never the caller's claim). + resolveMappingOwner: async (mappingRef: string) => { + // Resolve the TYPE server-side too — `resolveMapping` is structural + // (it only needs `accountRef` + `pull`), so without this an app could + // mint a webhook endpoint against any entity that happens to carry + // those shapes rather than a real `SyncMapping`. + const repo = await deps.getRepo(); + if (repo?.get(mappingRef)?.type !== SYNC_MAPPING_TYPE_URL) return null; + const ctx = await resolveMapping(mappingRef); + if (!ctx) return null; + return { accountId: ctx.mapping.accountRef, connectorAppId: ctx.connectorAppId }; + }, + ...(deps.getWebhookStore ? { getWebhookStore: deps.getWebhookStore } : {}), + ...(deps.ingressInfo ? { ingressInfo: deps.ingressInfo } : {}), + ...(deps.onWebhooksChanged ? { onWebhooksChanged: deps.onWebhooksChanged } : {}), getLedger: deps.getLedger, }; } diff --git a/packages/shell/src/main/index.ts b/packages/shell/src/main/index.ts index 38e83819..ca52ed7e 100644 --- a/packages/shell/src/main/index.ts +++ b/packages/shell/src/main/index.ts @@ -287,6 +287,7 @@ import { makeSpellcheckServiceHandler } from "./spellcheck/spellcheck-service"; import { AssetRefsRepository, AssetsRepository, EntitiesRepository } from "./storage/entities-repo"; import { AppsRepository } from "./storage/registry-repo/apps-repo"; import { BlocksRepository } from "./storage/registry-repo/blocks-repo"; +import { ConnectorWebhooksRepository } from "./storage/registry-repo/connector-webhooks-repo"; import { EntityTypesRepository } from "./storage/registry-repo/entity-types-repo"; import { FileWatchGrantsRepository } from "./storage/registry-repo/file-watch-grants-repo"; import { SchedulerFiresRepository } from "./storage/registry-repo/scheduler-fires-repo"; @@ -1284,7 +1285,12 @@ void app.whenReady().then(async () => { }, }); registerSpellcheckHandlers(); - registerLedgerHandlers(); + // Connector-6 — a Settings revoke re-derives the ingress route table now, so + // a revoked `network.ingress` kills every live webhook URL immediately + // instead of on the next automation-entity write. + registerLedgerHandlers({ + onGrantsChanged: () => void automationsDeployment?.rehydrate().catch(() => {}), + }); registerBrainstormProtocol(); registerBlockFrameProtocol({ getBlocksRepo: async () => { @@ -4065,6 +4071,14 @@ void app.whenReady().then(async () => { }), ); }; + // Connector-6 — the per-session registry store for connector webhook + // endpoints (hash-only secret custody; see connector-webhooks-repo.ts). + const getConnectorWebhookStore = async (): Promise => { + const session = getActiveVaultSession(); + if (!session) return null; + const db = await session.dataStores.open("registry"); + return new ConnectorWebhooksRepository(db); + }; const connectorsDeps = buildConnectorsServiceDeps({ egress: connectorsEgress, getRepo: getEntitiesRepoForActiveSession, @@ -4079,6 +4093,19 @@ void app.whenReady().then(async () => { title: n.title, body: n.body, }), + getWebhookStore: getConnectorWebhookStore, + // Thunks over the per-session deployment (declared below, assigned on + // session open) — only ever invoked at request time. + ingressInfo: async () => { + const info = await automationsDeployment?.webhookInfo(); + return { + loopbackBaseUrl: info?.loopbackBaseUrl ?? null, + relayBaseUrl: info?.relayBaseUrl ?? null, + }; + }, + onWebhooksChanged: async () => { + await automationsDeployment?.rehydrate().catch(() => {}); + }, }); // Connector-SEC1: token-endpoint egress is allowlisted from STATIC shell // code only — a `Connector/v1` entity can widen its `egressOrigins` but @@ -4220,6 +4247,22 @@ void app.whenReady().then(async () => { postAlert: (n) => getUiNotifyHost().post(n), deviceId: session.deviceEd25519.publicKeyBase64, egress: automationsEgress, + // Connector-6 — an authenticated connector webhook hit is a doorbell + // for the mapping's sync; routes come from the registry store, each + // gated on ITS connector app's `network.ingress` grant in the wiring. + connectorSync: { + runSync: (mappingId) => connectorsDeps.sync?.(mappingId) ?? Promise.resolve(null), + }, + listConnectorWebhooks: async () => { + const store = await getConnectorWebhookStore(); + if (!store) return []; + return store.listAll().map((r) => ({ + mappingId: r.mappingId, + routeId: r.routeId, + secretSha256: r.secretSha256, + connectorAppId: r.connectorAppId, + })); + }, }); automationsDeployment = deployment; const status = await deployment.start(); diff --git a/packages/shell/src/main/ipc/ledger-handlers.ts b/packages/shell/src/main/ipc/ledger-handlers.ts index 5b689c58..763ca191 100644 --- a/packages/shell/src/main/ipc/ledger-handlers.ts +++ b/packages/shell/src/main/ipc/ledger-handlers.ts @@ -42,7 +42,16 @@ export function normalizeEgressOrigin(raw: unknown): string | null { return url.origin; } -export function registerLedgerHandlers(): void { +export type LedgerHandlerDeps = { + /** Connector-6 — a grant revoke must take effect on the LIVE ingress route + * table, not on the next automation-entity write. Without this hook a + * revoked `network.ingress` left every already-minted webhook URL + * answering `202` (and, before the host's per-hit re-check, still pulling + * from the provider) until something unrelated re-derived the schedule. */ + onGrantsChanged?: () => void; +}; + +export function registerLedgerHandlers(deps: LedgerHandlerDeps = {}): void { ipcMain.handle( "ledger:list-grants-by-app", async (): Promise> => { @@ -68,7 +77,9 @@ export function registerLedgerHandlers(): void { const session = getActiveVaultSession(); if (!session) return false; const ledger = await session.capabilityLedger(); - return ledger.revoke(appId, capability, scope); + const revoked = ledger.revoke(appId, capability, scope); + if (revoked) deps.onGrantsChanged?.(); + return revoked; }, ); diff --git a/packages/shell/src/main/storage/registry-repo/connector-webhooks-repo.test.ts b/packages/shell/src/main/storage/registry-repo/connector-webhooks-repo.test.ts new file mode 100644 index 00000000..e47048d7 --- /dev/null +++ b/packages/shell/src/main/storage/registry-repo/connector-webhooks-repo.test.ts @@ -0,0 +1,81 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DataStores } from "../data-stores"; +import { ConnectorWebhooksRepository, sha256Hex } from "./connector-webhooks-repo"; + +const OWNER = { mappingId: "map-1", accountId: "acct-1", connectorAppId: "io.x.github" }; + +async function setup() { + const vaultDir = await mkdtemp(join(tmpdir(), "brainstorm-connwh-")); + const stores = new DataStores(vaultDir); + const db = await stores.open("registry"); + const repo = new ConnectorWebhooksRepository(db, () => 1000); + return { vaultDir, stores, db, repo }; +} + +describe("ConnectorWebhooksRepository (Connector-6)", () => { + let env: Awaited>; + beforeEach(async () => { + env = await setup(); + }); + afterEach(async () => { + await env.stores.close(); + await rm(env.vaultDir, { recursive: true, force: true }); + }); + + it("mints an endpoint and stores ONLY the SHA-256 of the secret", () => { + const minted = env.repo.mint(OWNER); + expect(minted.routeId).toMatch(/^cw_/); + expect(minted.secret.length).toBeGreaterThanOrEqual(32); + + const record = env.repo.getByMapping("map-1"); + expect(record).toMatchObject({ + routeId: minted.routeId, + mappingId: "map-1", + accountId: "acct-1", + connectorAppId: "io.x.github", + secretSha256: sha256Hex(minted.secret), + createdAt: 1000, + }); + // Custody: the plaintext appears nowhere in the persisted row. + expect(JSON.stringify(record)).not.toContain(minted.secret); + }); + + it("re-mint REPLACES the endpoint (rotation): fresh routeId + secret, old row gone", () => { + const first = env.repo.mint(OWNER); + const second = env.repo.mint(OWNER); + expect(second.routeId).not.toBe(first.routeId); + expect(second.secret).not.toBe(first.secret); + expect(env.repo.listAll()).toHaveLength(1); + expect(env.repo.getByMapping("map-1")?.routeId).toBe(second.routeId); + }); + + it("survives a reopen (restart persistence rides the registry)", async () => { + const minted = env.repo.mint(OWNER); + await env.stores.close(); + const stores = new DataStores(env.vaultDir); + const repo = new ConnectorWebhooksRepository(await stores.open("registry")); + expect(repo.getByMapping("map-1")?.routeId).toBe(minted.routeId); + await stores.close(); + env.stores = stores; + }); + + it("revokeByMapping removes exactly that endpoint", () => { + env.repo.mint(OWNER); + env.repo.mint({ mappingId: "map-2", accountId: "acct-1", connectorAppId: "io.x.github" }); + expect(env.repo.revokeByMapping("map-1")).toBe(true); + expect(env.repo.revokeByMapping("map-1")).toBe(false); + expect(env.repo.getByMapping("map-1")).toBeNull(); + expect(env.repo.getByMapping("map-2")).not.toBeNull(); + }); + + it("revokeByAccount kills every endpoint under the account (disconnect cascade)", () => { + env.repo.mint(OWNER); + env.repo.mint({ mappingId: "map-2", accountId: "acct-1", connectorAppId: "io.x.github" }); + env.repo.mint({ mappingId: "map-3", accountId: "acct-OTHER", connectorAppId: "io.x.slack" }); + expect(env.repo.revokeByAccount("acct-1")).toBe(2); + expect(env.repo.listAll().map((r) => r.mappingId)).toEqual(["map-3"]); + }); +}); diff --git a/packages/shell/src/main/storage/registry-repo/connector-webhooks-repo.ts b/packages/shell/src/main/storage/registry-repo/connector-webhooks-repo.ts new file mode 100644 index 00000000..eef7dd06 --- /dev/null +++ b/packages/shell/src/main/storage/registry-repo/connector-webhooks-repo.ts @@ -0,0 +1,143 @@ +/** + * ConnectorWebhooksRepository — CRUD on `registry.db.connector_webhooks` + * (Connector-6). + * + * The durable backing for connector webhook-in endpoints: one shell-minted + * `/wh//` route per `SyncMapping`, dispatching + * `connectors.sync(mappingId)` when an authenticated request arrives (doc 56 + * §Sync model). Endpoints survive restart here and die with the owning + * `ConnectorAccount` (`revokeByAccount` on disconnect). + * + * Custody invariant: the plaintext secret exists only in the `mint` return + * value (the one-time reveal the caller embeds in the endpoint URL). At rest + * this table holds the SHA-256 hex digest ONLY — registry.db is not yet + * encrypted (Stage 3b), so an offline read of the DB must not recover a live + * endpoint secret. No method returns a secret after mint; the ingress plane + * verifies by hashing the presented value (`webhookSecretMatchesSha256`). + */ + +import { createHash, randomBytes } from "node:crypto"; +import type { SqliteDatabase } from "@brainstorm-os/sqlite"; + +/** A persisted connector webhook endpoint — shell-internal (the digest never + * reaches an app; apps see only `routeId` + the base URLs). */ +export type ConnectorWebhookRecord = { + routeId: string; + mappingId: string; + accountId: string; + connectorAppId: string; + secretSha256: string; + createdAt: number; +}; + +/** The one-time mint result. `secret` is never stored — this is the only + * moment it exists in plaintext. */ +export type MintedConnectorWebhook = { + routeId: string; + secret: string; +}; + +type ConnectorWebhookRow = { + route_id: string; + mapping_id: string; + account_id: string; + connector_app_id: string; + secret_sha256: string; + created_at: number; +}; + +const genRouteId = (): string => `cw_${randomBytes(12).toString("base64url")}`; +/** 24 random bytes → 32 url-safe chars; fixed length so the constant-time + * compare's length gate is not an oracle. */ +const genSecret = (): string => randomBytes(24).toString("base64url"); + +export const sha256Hex = (value: string): string => + createHash("sha256").update(value, "utf8").digest("hex"); + +export class ConnectorWebhooksRepository { + constructor( + private readonly db: SqliteDatabase, + private readonly now: () => number = Date.now, + private readonly ids: { routeId(): string; secret(): string } = { + routeId: genRouteId, + secret: genSecret, + }, + ) {} + + /** Mint the endpoint for a mapping, REPLACING any existing one (mint on an + * existing mapping IS rotation: fresh routeId + fresh secret, the old URL + * goes dead atomically). Returns the plaintext secret exactly once. */ + mint(input: { + mappingId: string; + accountId: string; + connectorAppId: string; + }): MintedConnectorWebhook { + const routeId = this.ids.routeId(); + const secret = this.ids.secret(); + // Rotation is atomic: a failed INSERT must not leave the mapping with + // its previous endpoint deleted and no replacement (a silent outage + // where the provider keeps POSTing to a dead URL). + this.db.transaction(() => { + this.db.prepare("DELETE FROM connector_webhooks WHERE mapping_id = ?").run(input.mappingId); + this.db + .prepare( + `INSERT INTO connector_webhooks + (route_id, mapping_id, account_id, connector_app_id, secret_sha256, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + routeId, + input.mappingId, + input.accountId, + input.connectorAppId, + sha256Hex(secret), + this.now(), + ); + })(); + return { routeId, secret }; + } + + getByMapping(mappingId: string): ConnectorWebhookRecord | null { + const row = this.db + .prepare("SELECT * FROM connector_webhooks WHERE mapping_id = ?") + .get(mappingId) as ConnectorWebhookRow | undefined; + return row ? fromRow(row) : null; + } + + /** Every endpoint — shell-internal (feeds the ingress route table). */ + listAll(): ConnectorWebhookRecord[] { + const rows = this.db + .prepare("SELECT * FROM connector_webhooks ORDER BY created_at") + .all() as ConnectorWebhookRow[]; + return rows.map(fromRow); + } + + /** Revoke a mapping's endpoint. Returns whether a row was removed. */ + revokeByMapping(mappingId: string): boolean { + const result = this.db + .prepare("DELETE FROM connector_webhooks WHERE mapping_id = ?") + .run(mappingId); + return Number(result.changes) > 0; + } + + /** Account disconnect — every endpoint under the account dies with it + * (doc 56 §Trust: revoke is never a silent half-state). Returns the + * number of endpoints removed. */ + revokeByAccount(accountId: string): number { + const result = this.db + .prepare("DELETE FROM connector_webhooks WHERE account_id = ?") + .run(accountId); + return Number(result.changes); + } +} + +function fromRow(r: ConnectorWebhookRow): ConnectorWebhookRecord { + return { + routeId: r.route_id, + mappingId: r.mapping_id, + accountId: r.account_id, + connectorAppId: r.connector_app_id, + secretSha256: r.secret_sha256, + createdAt: r.created_at, + }; +} diff --git a/packages/shell/src/main/storage/registry-repo/index.ts b/packages/shell/src/main/storage/registry-repo/index.ts index 519993c0..98ef0758 100644 --- a/packages/shell/src/main/storage/registry-repo/index.ts +++ b/packages/shell/src/main/storage/registry-repo/index.ts @@ -10,6 +10,7 @@ import type { SqliteDatabase } from "@brainstorm-os/sqlite"; import { AppsRepository } from "./apps-repo"; import { BlocksRepository } from "./blocks-repo"; +import { ConnectorWebhooksRepository } from "./connector-webhooks-repo"; import { EntityTypesRepository } from "./entity-types-repo"; import { FileWatchGrantsRepository } from "./file-watch-grants-repo"; import { IntentsRepository } from "./intents-repo"; @@ -20,6 +21,7 @@ import { WidgetsRepository } from "./widgets-repo"; export { AppsRepository, BlocksRepository, + ConnectorWebhooksRepository, EntityTypesRepository, FileWatchGrantsRepository, IntentsRepository, @@ -29,6 +31,7 @@ export { }; export type { AppRecord } from "./apps-repo"; export type { BlockRecord } from "./blocks-repo"; +export type { ConnectorWebhookRecord, MintedConnectorWebhook } from "./connector-webhooks-repo"; export type { EntityTypeRecord } from "./entity-types-repo"; export type { FileWatchGrant, FileWatchGrantSummary } from "./file-watch-grants-repo"; export type { IntentQuery, IntentRecord } from "./intents-repo"; @@ -46,6 +49,7 @@ export class RegistryRepositories { readonly intents: IntentsRepository; readonly schedulerFires: SchedulerFiresRepository; readonly fileWatchGrants: FileWatchGrantsRepository; + readonly connectorWebhooks: ConnectorWebhooksRepository; constructor(db: SqliteDatabase) { this.apps = new AppsRepository(db); @@ -56,5 +60,6 @@ export class RegistryRepositories { this.intents = new IntentsRepository(db); this.schedulerFires = new SchedulerFiresRepository(db); this.fileWatchGrants = new FileWatchGrantsRepository(db); + this.connectorWebhooks = new ConnectorWebhooksRepository(db); } } diff --git a/packages/shell/src/main/storage/registry-schema.ts b/packages/shell/src/main/storage/registry-schema.ts index bb8e98e9..19e2d1f2 100644 --- a/packages/shell/src/main/storage/registry-schema.ts +++ b/packages/shell/src/main/storage/registry-schema.ts @@ -275,4 +275,29 @@ export const REGISTRY_MIGRATIONS: SqliteMigration[] = [ ); }, }, + { + version: 12, + description: "registry.db v12 — connector_webhooks (Connector-6 webhook-in endpoints)", + up: (db) => { + // Connector-6 — shell-minted inbound webhook endpoints for connector + // SyncMappings (doc 56 §Sync model: a mapping's Webhook trigger whose + // handler is `connectors.sync(mappingRef)`). Custody invariant: + // `secret_sha256` is the ONLY stored form of the endpoint secret — the + // plaintext is returned exactly once at mint (registry.db is not yet + // encrypted at rest, Stage 3b, so a disk read must not recover a live + // secret). One endpoint per mapping (re-mint = rotate); rows die with + // the mapping's account on disconnect. LOCAL — never syncs. + db.exec(` + CREATE TABLE connector_webhooks ( + route_id TEXT PRIMARY KEY, + mapping_id TEXT NOT NULL UNIQUE, + account_id TEXT NOT NULL, + connector_app_id TEXT NOT NULL, + secret_sha256 TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + `); + db.exec("CREATE INDEX idx_connector_webhooks_account ON connector_webhooks (account_id);"); + }, + }, ];