From 5965e71513bd5ef4228790598eac10ce714d1616 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 18:45:25 -0700 Subject: [PATCH 1/2] feat(hub)!: mount @corbits/webhooks; delete webhook-triggers and collections (CL-8389) Pin apps/hub to github:corbitsdev/webhooks#570bd526 (v0.1.2) and mount it once alongside the hub's other Corbits libraries; a webhook is now a tenant credential with metadata.webhook, verified by POST /api/hooks, firing the workflow's onTrigger mail as the run principal. A thin, in-repo adapter narrows HookMailRouter's unknown-typed payloads back to sidecarRouter's own types at the boundary -- no library or vendored Interchange code touched. Delete packages/webhook-triggers (own table, ingress, and management routes) and packages/collections (webhook-triggers' only consumer), their migrateHub step, and apps/web's webhook-secret-panel and webhook-triggers-api -- a webhook is created through the existing credentials section. --- ARCHITECTURE.md | 8 +- apps/hub/package.json | 2 +- apps/hub/src/migrate.ts | 7 +- apps/hub/src/server.ts | 76 ++-- apps/web/src/settings/index.ts | 2 - apps/web/src/settings/styles.css | 50 +-- .../web/src/settings/webhook-secret-panel.tsx | 111 ------ apps/web/src/webhook-triggers-api.ts | 157 -------- .../settings/webhook-secret-panel.test.tsx | 91 ----- apps/web/test/webhook-triggers-api.test.ts | 160 -------- bun.lock | 39 +- package.json | 3 +- packages/collections/LICENSE | 176 --------- packages/collections/README.md | 27 -- packages/collections/package.json | 19 - packages/collections/src/expiring-map.test.ts | 151 -------- packages/collections/src/expiring-map.ts | 66 ---- packages/collections/src/index.ts | 1 - packages/collections/tsconfig.json | 15 - packages/collections/tsconfig.src.json | 13 - packages/webhook-triggers/LICENSE | 176 --------- packages/webhook-triggers/README.md | 50 --- packages/webhook-triggers/package.json | 36 -- .../webhook-triggers/src/crypto-cache.test.ts | 88 ----- packages/webhook-triggers/src/crypto-cache.ts | 36 -- packages/webhook-triggers/src/index.ts | 27 -- .../webhook-triggers/src/ingress-routes.ts | 110 ------ packages/webhook-triggers/src/launch.ts | 229 ----------- .../webhook-triggers/src/management-routes.ts | 229 ----------- packages/webhook-triggers/src/mapping.ts | 34 -- packages/webhook-triggers/src/migrations.ts | 59 --- packages/webhook-triggers/src/schema.ts | 54 --- packages/webhook-triggers/src/signature.ts | 104 ----- packages/webhook-triggers/src/store.ts | 228 ----------- .../test/ingress-routes.test.ts | 206 ---------- packages/webhook-triggers/test/launch.test.ts | 360 ------------------ .../test/management-routes.test.ts | 190 --------- .../webhook-triggers/test/mapping.test.ts | 39 -- .../webhook-triggers/test/migrations.test.ts | 81 ---- .../webhook-triggers/test/signature.test.ts | 93 ----- .../test/store.drizzle.test.ts | 204 ---------- .../webhook-triggers/test/test-support.ts | 136 ------- packages/webhook-triggers/tsconfig.json | 8 - 43 files changed, 38 insertions(+), 3913 deletions(-) delete mode 100644 apps/web/src/settings/webhook-secret-panel.tsx delete mode 100644 apps/web/src/webhook-triggers-api.ts delete mode 100644 apps/web/test/settings/webhook-secret-panel.test.tsx delete mode 100644 apps/web/test/webhook-triggers-api.test.ts delete mode 100644 packages/collections/LICENSE delete mode 100644 packages/collections/README.md delete mode 100644 packages/collections/package.json delete mode 100644 packages/collections/src/expiring-map.test.ts delete mode 100644 packages/collections/src/expiring-map.ts delete mode 100644 packages/collections/src/index.ts delete mode 100644 packages/collections/tsconfig.json delete mode 100644 packages/collections/tsconfig.src.json delete mode 100644 packages/webhook-triggers/LICENSE delete mode 100644 packages/webhook-triggers/README.md delete mode 100644 packages/webhook-triggers/package.json delete mode 100644 packages/webhook-triggers/src/crypto-cache.test.ts delete mode 100644 packages/webhook-triggers/src/crypto-cache.ts delete mode 100644 packages/webhook-triggers/src/index.ts delete mode 100644 packages/webhook-triggers/src/ingress-routes.ts delete mode 100644 packages/webhook-triggers/src/launch.ts delete mode 100644 packages/webhook-triggers/src/management-routes.ts delete mode 100644 packages/webhook-triggers/src/mapping.ts delete mode 100644 packages/webhook-triggers/src/migrations.ts delete mode 100644 packages/webhook-triggers/src/schema.ts delete mode 100644 packages/webhook-triggers/src/signature.ts delete mode 100644 packages/webhook-triggers/src/store.ts delete mode 100644 packages/webhook-triggers/test/ingress-routes.test.ts delete mode 100644 packages/webhook-triggers/test/launch.test.ts delete mode 100644 packages/webhook-triggers/test/management-routes.test.ts delete mode 100644 packages/webhook-triggers/test/mapping.test.ts delete mode 100644 packages/webhook-triggers/test/migrations.test.ts delete mode 100644 packages/webhook-triggers/test/signature.test.ts delete mode 100644 packages/webhook-triggers/test/store.drizzle.test.ts delete mode 100644 packages/webhook-triggers/test/test-support.ts delete mode 100644 packages/webhook-triggers/tsconfig.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d9a2ed48b9..7a9feafcb8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,9 +11,9 @@ handful of domain packages built on top. `@intx/hub-api` routes (auth, tenants, principals, grants, workflow deploy/run) plus one composition block that adds Corbits libraries — `@corbits/mailbox` (chat), `@corbits/memory` (recall), `@corbits/artifacts` - (files), `@corbits/cron` (scheduling) — and the remaining in-repo - packages: `webhook-triggers` (inbound webhook ingress), `workflows` - (author routes), and `agent-directory`. Credentials are stored through + (files), `@corbits/cron` (scheduling), `@corbits/webhooks` (inbound + webhook ingress) — and the remaining in-repo packages: `workflows` + (author routes) and `agent-directory`. Credentials are stored through Interchange's own stock credential routes; nothing in this repo wraps or replaces them. - **`apps/sidecar`** — a byte-for-byte copy of Interchange's own sidecar. @@ -48,7 +48,7 @@ stock workflow-deploy route. `tools/*` and `skills/*` are ordinary ## Data -Custom tables — `webhook-triggers`, `agent-directory`, and so on — live +Custom tables — `agent-directory` and so on — live on their own Postgres schema, each with foreign keys back to Interchange's `tenant`/`principal` tables in `public`. Each package ships one idempotent migration, which the hub applies itself at boot. Anything diff --git a/apps/hub/package.json b/apps/hub/package.json index 23de6c59de..a29d51e8d1 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -23,7 +23,7 @@ "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#e85b81e4a2e0cc318dd33a4f0f92337b6fac7f38", "@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913", "@corbits/url-path": "workspace:*", - "@corbits/webhook-triggers": "workspace:*", + "@corbits/webhooks": "github:corbitsdev/webhooks#570bd52688920c0ce018a8fbcd046199fa4125dd", "@corbits/workflows": "workspace:*", "@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider#9f2d4bac40ea075df092fef807404a13c638cf14", "@intx/authz": "0.3.0", diff --git a/apps/hub/src/migrate.ts b/apps/hub/src/migrate.ts index 53af3cdeb5..e4fc63004a 100644 --- a/apps/hub/src/migrate.ts +++ b/apps/hub/src/migrate.ts @@ -7,7 +7,6 @@ // service, no ledger table, just the hub doing the work itself. import { runMigrations } from "@intx/db"; import { applyCronMigrations } from "@corbits/cron"; -import { applyWebhookTriggersMigrations } from "@corbits/webhook-triggers"; import { createMailboxDb, runMailboxMigrations } from "@corbits/mailbox"; import { runArtifactMigrations } from "@corbits/artifacts"; import { runMemoryMigrations } from "@corbits/memory"; @@ -30,8 +29,9 @@ function databaseUrlFrom(config: HubMigrateConfig): string { /** * Apply the platform schema plus every mounted Corbits package's own - * migration, in the order the hub mounts them: cron, webhook-triggers, - * mailbox, artifacts, memory. `db` is the same drizzle handle the rest + * migration, in the order the hub mounts them: cron, mailbox, artifacts, + * memory. `@corbits/webhooks` owns no table of its own, so it has no + * migration step. `db` is the same drizzle handle the rest * of the hub uses, so `runArtifactMigrations` (which takes a drizzle * db, not a URL) shares the one connection pool rather than opening * its own. @@ -45,7 +45,6 @@ export async function migrateHub( await runMigrations(config, { schema }); await applyCronMigrations(databaseUrl, { tenantSchema: schema }); - await applyWebhookTriggersMigrations(databaseUrl); const mailboxDb = createMailboxDb(databaseUrl); try { diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts index c025521ef0..45cd0f2f6c 100644 --- a/apps/hub/src/server.ts +++ b/apps/hub/src/server.ts @@ -7,8 +7,8 @@ import { resolveFrameSenderKey, resolveSenderKey, } from "@intx/db"; -import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; -import { and, eq } from "drizzle-orm"; +import { tenant as tenantTable } from "@intx/db/schema"; +import { eq } from "drizzle-orm"; import { createEnvKeyCredentialCipher, sha256 } from "@intx/crypto"; import { hexDecode, hexEncode, type SidecarCapabilityRule } from "@intx/types"; import { @@ -69,13 +69,7 @@ import { createHubMailboxAuthorizeSender, createHubPersistMailWithSessionEnsure, } from "./mailbox-persist"; -import { - createDrizzleWebhookTriggerStore, - createWebhookIngressRoutes, - createWebhookTriggerRoutes, - launchWebhookTrigger, - createCryptoProviderCache, -} from "@corbits/webhook-triggers"; +import { installWebhooks, type HookMailRouter } from "@corbits/webhooks"; import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes } from "@corbits/workflows"; import { createProcessSidecarProvisioner, @@ -622,48 +616,28 @@ export async function createHubServer({ }), ); - const webhookTriggerStore = createDrizzleWebhookTriggerStore(db, credentialCipher); - const cryptoProviders = createCryptoProviderCache(); - app.route( - `${TENANT_PREFIX}/webhook-triggers`, - createWebhookTriggerRoutes({ - store: webhookTriggerStore, - requireGrant: createRequireGrant({ grantStore, conditionRegistry: grantConditionRegistry }), - workflowDefinitionInTenant: async (tenantId, definitionId) => { - const row = await db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, definitionId), - eq(workflowDefinition.tenantId, tenantId), - ), - columns: { id: true }, - }); - return row !== undefined; - }, - }), - ); - const isSidecarRoutable = (address: string) => - sidecarRouter.getRoutableAddresses().includes(address); - app.route( - "/api/webhooks", - createWebhookIngressRoutes({ - store: webhookTriggerStore, - launch: (trigger, payload) => - launchWebhookTrigger( - { - db, - sidecarRouter, - repoStore: agentRepoStore.repoStore, - workflowAllocationService, - credentialCipher, - eventCollectors, - isRoutable: isSidecarRoutable, - cryptoProviderCache: cryptoProviders, - }, - trigger, - payload, - ), - }), - ); + // `HookMailRouter` types its payloads as `unknown` at the package + // boundary; this just narrows them back to `sidecarRouter`'s own types + // on the way through, with no behavior change. + const webhookMailRouter: HookMailRouter = { + routeMail: (address, rawMessage, authenticatedSender, messageId) => + sidecarRouter.routeMail(address, rawMessage, authenticatedSender, messageId), + sendRunGrants: (address, runId, stepGrants, senderIdentities) => + sidecarRouter.sendRunGrants( + address, + runId, + stepGrants as Parameters[2], + senderIdentities as Parameters[3], + ), + }; + + await installWebhooks({ + app, + db, + credentialCipher, + principalKeyStore, + router: webhookMailRouter, + }); // --------------------------------------------------------------------- // End of Corbits mount block. diff --git a/apps/web/src/settings/index.ts b/apps/web/src/settings/index.ts index 3e70c265e4..d60f2d365f 100644 --- a/apps/web/src/settings/index.ts +++ b/apps/web/src/settings/index.ts @@ -17,8 +17,6 @@ export { RolesSection, RolesTable, RoleAssignments, CreateRoleDialog } from "./r export { GrantsSection, GrantsTable, CreateGrantDialog } from "./grants-section"; export { CredentialsSection } from "./credentials-section"; -export { CopyButton, CopyableCodeRow, WebhookSecretPanel } from "./webhook-secret-panel"; - export { grantPreviewSentence, expiryIsoFromPreset, expiryLabelFromPreset } from "./grant-preview"; export type { GrantPreviewInput } from "./grant-preview"; export { KindCards } from "./kind-cards"; diff --git a/apps/web/src/settings/styles.css b/apps/web/src/settings/styles.css index 3dc65076cb..e42d3f8d06 100644 --- a/apps/web/src/settings/styles.css +++ b/apps/web/src/settings/styles.css @@ -592,52 +592,6 @@ opacity: 0.75; } -.settings-webhook-field { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.settings-webhook-field-label { - font-size: 0.75rem; - font-weight: 500; -} - -.settings-webhook-field-box { - display: flex; - align-items: center; - gap: 0.375rem; - border: 1px solid var(--border); - border-radius: 0; - background: color-mix(in srgb, var(--foreground) 4%, transparent); - padding: 0.375rem 0.625rem; -} - -.settings-webhook-field-code { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 0.75rem; -} - -.settings-webhook-routine-row { - display: flex; - flex-direction: column; - gap: 0.5rem; - border: 1px solid var(--border); - border-radius: var(--radius, 0); - padding: 0.75rem; -} - -.settings-webhook-routine-row-header { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - .settings-advanced-disclosure { border-top: 1px solid var(--border); padding-top: 0.75rem; @@ -673,8 +627,8 @@ } /* Square, not pill-shaped — consistent with this app's other react-ui - primitive overrides (e.g. .settings-webhook-field-box above), applied - here so every Switch in settings-ui gets it, not just this one. */ + primitive overrides, applied here so every Switch in settings-ui gets + it, not just this one. */ [role="switch"], [role="switch"] > span { border-radius: 0; diff --git a/apps/web/src/settings/webhook-secret-panel.tsx b/apps/web/src/settings/webhook-secret-panel.tsx deleted file mode 100644 index 6caad23b12..0000000000 --- a/apps/web/src/settings/webhook-secret-panel.tsx +++ /dev/null @@ -1,111 +0,0 @@ -// The shared "shown once" webhook-secret reveal: a copyable hook URL, a -// copyable signing secret, and an optional sample payload. Every webhook -// UI in the app renders this after a create or a rotate — the hub returns -// a trigger's secret exactly once (see -// `packages/webhook-triggers/src/management-routes.ts`), so this is the -// only place that value is ever on screen. First built inline in the -// Routines page's webhook panel; lifted here so the Connections surface's -// Granola webhook card renders the identical panel instead of a -// second hand-rolled copy. - -import { Button } from "@corbits/react-ui"; -import { Copy } from "@/lib/icons"; -import { useEffect, useRef, useState } from "react"; - -/** Copies `value` to the clipboard, showing "Copied" for 1.5s. */ -export function CopyButton({ value, label }: { readonly value: string; readonly label: string }) { - const [copied, setCopied] = useState(false); - const resetTimer = useRef | null>(null); - useEffect(() => { - return () => { - if (resetTimer.current !== null) clearTimeout(resetTimer.current); - }; - }, []); - return ( - - ); -} - -/** - * A labeled, copyable value in a bordered field row — the Hook URL and - * Signing secret rows share this shape whether the secret is freshly - * revealed (`WebhookSecretPanel`) or the field is a persistent read of a - * value that's always safe to show (a hook URL never rotates). Zero - * radius, like every other settings surface — see `styles.css`. - */ -export function CopyableCodeRow({ - label, - value, - copyLabel, -}: { - readonly label: string; - readonly value: string; - readonly copyLabel: string; -}) { - return ( -
- {label} -
- {value} - -
-
- ); -} - -/** - * The generated hook URL, a freshly-issued secret, and (optionally) a - * sample payload — shown right after create or rotate. The secret shown - * here is never fetched back later: the hub returns it exactly once, so - * this panel only ever renders from a value the caller just received. - */ -export function WebhookSecretPanel({ - url, - secret, - samplePayload, -}: { - readonly url: string; - readonly secret: string; - readonly samplePayload?: string; -}) { - return ( -
-

- This secret is shown once — copy it now. It signs every delivery to this URL; losing it - means rotating for a new one. -

- - - {samplePayload !== undefined && ( -
- Example payload -
-            {samplePayload}
-          
-

- Any valid JSON body with a matching{" "} - X-Webhook-Signature header starts a run. -

-
- )} -
- ); -} diff --git a/apps/web/src/webhook-triggers-api.ts b/apps/web/src/webhook-triggers-api.ts deleted file mode 100644 index 9d272a8b66..0000000000 --- a/apps/web/src/webhook-triggers-api.ts +++ /dev/null @@ -1,157 +0,0 @@ -// The Routines page's seam to `@corbits/webhook-triggers`' HTTP routes -// (see packages/webhook-triggers/src/management-routes.ts), mirroring -// `routines-api.ts`: tenant-scoped request functions, each response -// validated at the boundary with an arktype schema owned here. -// -// A webhook trigger's secret is never stored client-side beyond the -// component state that shows it — the hub returns it exactly once, on -// create or rotate, and every other response (list/get) omits it -// entirely. Reloading the page or navigating away is enough to lose it -// again; that is the point, not a bug — see the rotate flow in -// `pages/routines-page.tsx`. -import { type } from "arktype"; -import type { ArkErrors } from "arktype"; - -import { ApiQueryError, UnauthenticatedError } from "@/lib/api-query"; - -export const WebhookTrigger = type({ - id: "string", - tenantId: "string", - name: "string", - workflowDefinitionId: "string", - inputTemplate: "string", - enabled: "boolean", - createdBy: "string", - createdAt: "string", - lastFiredAt: "string | null", -}); -export type WebhookTrigger = typeof WebhookTrigger.infer; - -const WebhookTriggerWithSecret = WebhookTrigger.and({ secret: "string" }); -export type WebhookTriggerWithSecret = typeof WebhookTriggerWithSecret.infer; - -export type CreateWebhookTriggerInput = { - readonly name: string; - readonly workflowDefinitionId: string; - readonly inputTemplate: string; -}; - -type Validator = (data: unknown) => T | ArkErrors; - -async function request(path: string, schema: Validator, init?: RequestInit): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new ApiQueryError(cause instanceof Error ? cause.message : String(cause)); - } - if (response.status === 401) { - throw new UnauthenticatedError(); - } - if (!response.ok) { - const detail = await response - .json() - .then((body: { error?: { message?: string } }) => body.error?.message ?? "") - .catch(() => ""); - throw new ApiQueryError( - `The server answered ${response.status} for ${path}.${detail === "" ? "" : ` ${detail}`}`, - response.status, - ); - } - if (response.status === 204) return undefined as T; - const body: unknown = await response.json().catch(() => undefined); - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new ApiQueryError(`Unexpected response shape from ${path}: ${parsed.summary}`); - } - return parsed; -} - -export function listWebhookTriggers(tenantId: string): Promise { - return request( - `/api/tenants/${tenantId}/webhook-triggers`, - type({ items: WebhookTrigger.array() }), - ).then((page) => page.items); -} - -export function getWebhookTrigger(tenantId: string, id: string): Promise { - return request(`/api/tenants/${tenantId}/webhook-triggers/${id}`, WebhookTrigger); -} - -/** - * Default input mapping for a routine's webhook binding: a routine's - * webhook trigger currently sends a fixed message rather than an - * editable `{{path.to.field}}` template — see - * `packages/webhook-triggers/src/mapping.ts`. Any well-formed JSON - * payload with a valid signature starts the routine; templated field - * mapping is a follow-on, not something this create flow exposes yet. - */ -export const DEFAULT_WEBHOOK_INPUT_TEMPLATE = "New webhook delivery."; - -export function createWebhookTrigger( - tenantId: string, - input: CreateWebhookTriggerInput, -): Promise { - return request(`/api/tenants/${tenantId}/webhook-triggers`, WebhookTriggerWithSecret, { - method: "POST", - body: JSON.stringify(input), - }); -} - -export function rotateWebhookTriggerSecret( - tenantId: string, - id: string, -): Promise { - return request( - `/api/tenants/${tenantId}/webhook-triggers/${id}/rotate-secret`, - WebhookTriggerWithSecret, - { method: "POST", body: JSON.stringify({}) }, - ); -} - -export function setWebhookTriggerEnabled( - tenantId: string, - id: string, - enabled: boolean, -): Promise { - return request(`/api/tenants/${tenantId}/webhook-triggers/${id}/enabled`, WebhookTrigger, { - method: "POST", - body: JSON.stringify({ enabled }), - }); -} - -export function deleteWebhookTrigger(tenantId: string, id: string): Promise { - return request(`/api/tenants/${tenantId}/webhook-triggers/${id}`, type("unknown"), { - method: "DELETE", - }).then(() => undefined); -} - -/** The URL a sender posts deliveries to — built client-side (no route - * returns it) since it is a pure function of the trigger id and origin: - * `POST /api/webhooks/:triggerId`, mounted outside tenant auth (see - * `packages/webhook-triggers/src/ingress-routes.ts`). */ -export function webhookTriggerUrl(triggerId: string): string { - const origin = typeof window !== "undefined" ? window.location.origin : ""; - return `${origin}/api/webhooks/${triggerId}`; -} - -/** - * A representative example of a POST body this trigger accepts — - * documentation, not a schema: `@corbits/webhook-triggers` places no - * constraint on payload shape beyond "valid JSON" (see - * `ingress-routes.ts`), so this is illustrative only, never validated - * against a real delivery. - */ -export function sampleWebhookPayload(): string { - return JSON.stringify( - { - event: "example.event", - data: { id: "123", summary: "Example payload" }, - }, - null, - 2, - ); -} diff --git a/apps/web/test/settings/webhook-secret-panel.test.tsx b/apps/web/test/settings/webhook-secret-panel.test.tsx deleted file mode 100644 index f8163bdd74..0000000000 --- a/apps/web/test/settings/webhook-secret-panel.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -// The "shown once" webhook-secret panel: confirms it renders the hook URL -// and secret exactly once, mirrors the copy the Routines page's webhook -// panel already commits to, and only renders the sample-payload block -// when a caller supplies one. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; - -import { WebhookSecretPanel } from "../../src/settings/webhook-secret-panel"; - -function mount(element: React.ReactElement): { - container: HTMLDivElement; - root: Root; -} { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - act(() => { - root.render(element); - }); - return { container, root }; -} - -let mounted: { container: HTMLDivElement; root: Root } | null = null; -afterEach(() => { - if (mounted !== null) { - act(() => mounted?.root.unmount()); - mounted.container.remove(); - mounted = null; - } -}); - -describe("WebhookSecretPanel", () => { - test("shows the hook URL, the secret, and a shown-once warning", () => { - mounted = mount( - , - ); - expect(document.body.textContent).toContain("https://bench.example.com/api/webhooks/wht_1"); - expect(document.body.textContent).toContain("sec_abc123"); - expect(document.body.textContent).toContain("shown once"); - }); - - test("omits the sample payload block when none is given", () => { - mounted = mount(); - expect(document.body.textContent).not.toContain("Example payload"); - }); - - test("renders the sample payload when given", () => { - mounted = mount( - , - ); - expect(document.body.textContent).toContain("call.completed"); - expect(document.body.textContent).toContain("Example payload"); - }); - - test("copy buttons write the URL and secret to the clipboard", async () => { - const written: string[] = []; - const originalClipboard = navigator.clipboard; - Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: { - writeText: (value: string) => { - written.push(value); - return Promise.resolve(); - }, - }, - }); - try { - mounted = mount(); - const copyButtons = [...document.body.querySelectorAll("button")].filter((button) => - button.textContent?.includes("Copy"), - ); - expect(copyButtons.length).toBe(2); - act(() => copyButtons[0]?.click()); - act(() => copyButtons[1]?.click()); - await act(() => Promise.resolve()); - expect(written).toEqual(["https://x/api/webhooks/wht_1", "sec_xyz"]); - } finally { - Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: originalClipboard, - }); - } - }); -}); diff --git a/apps/web/test/webhook-triggers-api.test.ts b/apps/web/test/webhook-triggers-api.test.ts deleted file mode 100644 index bc0f1fdab9..0000000000 --- a/apps/web/test/webhook-triggers-api.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -// The Routines page's webhook-triggers client: request paths/bodies, -// boundary parsing, and the secret-never-persisted-client-side contract -// (only create/rotate responses carry it, and only because the hub's -// own routes only ever put it there — see -// packages/webhook-triggers/src/management-routes.ts). - -import { afterEach, describe, expect, test } from "bun:test"; - -import { ApiQueryError, UnauthenticatedError } from "@/lib/api-query"; -import { - createWebhookTrigger, - deleteWebhookTrigger, - getWebhookTrigger, - listWebhookTriggers, - rotateWebhookTriggerSecret, - sampleWebhookPayload, - setWebhookTriggerEnabled, - webhookTriggerUrl, -} from "../src/webhook-triggers-api"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -type RecordedCall = { - readonly path: string; - readonly init: RequestInit | undefined; -}; - -function stubFetch(respond: (path: string) => Response): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - calls.push({ path, init }); - return Promise.resolve(respond(path)); - }) as typeof fetch; - return calls; -} - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -const triggerFixture = { - id: "wht_1", - tenantId: "tnt_1", - name: "Support digest", - workflowDefinitionId: "wfd_1", - inputTemplate: "New webhook delivery.", - enabled: true, - createdBy: "usr_1", - createdAt: "2026-01-01T00:00:00.000Z", - lastFiredAt: null, -}; - -describe("listWebhookTriggers", () => { - test("hits the tenant-scoped list route and unwraps items", async () => { - const calls = stubFetch(() => json({ items: [triggerFixture] })); - const items = await listWebhookTriggers("tnt_1"); - expect(items).toEqual([triggerFixture]); - expect(calls[0]?.path).toBe("/api/tenants/tnt_1/webhook-triggers"); - }); -}); - -describe("getWebhookTrigger", () => { - test("hits the id route", async () => { - const calls = stubFetch(() => json(triggerFixture)); - const item = await getWebhookTrigger("tnt_1", "wht_1"); - expect(item).toEqual(triggerFixture); - expect(calls[0]?.path).toBe("/api/tenants/tnt_1/webhook-triggers/wht_1"); - }); -}); - -describe("createWebhookTrigger", () => { - test("POSTs the input and returns the one-time secret", async () => { - const calls = stubFetch(() => json({ ...triggerFixture, secret: "generated-secret" }, 201)); - const result = await createWebhookTrigger("tnt_1", { - name: "Support digest", - workflowDefinitionId: "wfd_1", - inputTemplate: "New webhook delivery.", - }); - expect(result.secret).toBe("generated-secret"); - expect(calls[0]?.init?.method).toBe("POST"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - name: "Support digest", - workflowDefinitionId: "wfd_1", - inputTemplate: "New webhook delivery.", - }); - }); - - test("rejects a response missing the secret field", async () => { - stubFetch(() => json(triggerFixture, 201)); - await expect( - createWebhookTrigger("tnt_1", { - name: "x", - workflowDefinitionId: "wfd_1", - inputTemplate: "x", - }), - ).rejects.toBeInstanceOf(ApiQueryError); - }); -}); - -describe("rotateWebhookTriggerSecret", () => { - test("POSTs to the rotate route and returns the new one-time secret", async () => { - const calls = stubFetch(() => json({ ...triggerFixture, secret: "rotated-secret" })); - const result = await rotateWebhookTriggerSecret("tnt_1", "wht_1"); - expect(result.secret).toBe("rotated-secret"); - expect(calls[0]?.path).toBe("/api/tenants/tnt_1/webhook-triggers/wht_1/rotate-secret"); - expect(calls[0]?.init?.method).toBe("POST"); - }); -}); - -describe("setWebhookTriggerEnabled", () => { - test("POSTs the enabled flag", async () => { - const calls = stubFetch(() => json({ ...triggerFixture, enabled: false })); - const result = await setWebhookTriggerEnabled("tnt_1", "wht_1", false); - expect(result.enabled).toBe(false); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - enabled: false, - }); - }); -}); - -describe("deleteWebhookTrigger", () => { - test("DELETEs the trigger", async () => { - const calls = stubFetch(() => new Response(null, { status: 204 })); - await deleteWebhookTrigger("tnt_1", "wht_1"); - expect(calls[0]?.init?.method).toBe("DELETE"); - }); -}); - -describe("error handling", () => { - test("a 404 surfaces the hub's error message", async () => { - stubFetch(() => json({ error: { code: "not_found", message: "trigger not found" } }, 404)); - await expect(getWebhookTrigger("tnt_1", "missing")).rejects.toThrow(/trigger not found/); - }); - - test("a 401 throws an UnauthenticatedError", async () => { - stubFetch(() => new Response(null, { status: 401 })); - await expect(listWebhookTriggers("tnt_1")).rejects.toBeInstanceOf(UnauthenticatedError); - }); -}); - -describe("webhookTriggerUrl", () => { - test("builds the ingress path for a trigger id", () => { - expect(webhookTriggerUrl("wht_1")).toContain("/api/webhooks/wht_1"); - }); -}); - -describe("sampleWebhookPayload", () => { - test("is valid, non-empty JSON", () => { - const parsed: unknown = JSON.parse(sampleWebhookPayload()); - expect(parsed).not.toBeNull(); - expect(typeof parsed).toBe("object"); - }); -}); diff --git a/bun.lock b/bun.lock index 2a531291d8..4255bca669 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,6 @@ "@corbits/codex-provider": "github:corbitsdev/corbits-codex-provider#a1f475f341261c05fa8dade0c82f1246c175b5b3", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#e85b81e4a2e0cc318dd33a4f0f92337b6fac7f38", "@corbits/ollama-adapter": "github:corbitsdev/corbits-ollama-adapter#0387eb17bee5ac87530e9c48bb3bffd9a6998ded", - "@corbits/webhook-triggers": "workspace:*", "@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider#9f2d4bac40ea075df092fef807404a13c638cf14", "@intx/crypto": "workspace:*", "@intx/db": "workspace:*", @@ -62,7 +61,7 @@ "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#e85b81e4a2e0cc318dd33a4f0f92337b6fac7f38", "@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913", "@corbits/url-path": "workspace:*", - "@corbits/webhook-triggers": "workspace:*", + "@corbits/webhooks": "github:corbitsdev/webhooks#570bd52688920c0ce018a8fbcd046199fa4125dd", "@corbits/workflows": "workspace:*", "@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider#9f2d4bac40ea075df092fef807404a13c638cf14", "@intx/authz": "0.3.0", @@ -208,14 +207,6 @@ "typescript": "catalog:", }, }, - "packages/collections": { - "name": "@corbits/collections", - "version": "0.0.1", - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/cron": { "name": "@corbits/cron", "version": "0.0.1", @@ -270,30 +261,6 @@ "typescript": "catalog:", }, }, - "packages/webhook-triggers": { - "name": "@corbits/webhook-triggers", - "version": "0.0.1", - "dependencies": { - "@corbits/collections": "workspace:*", - "@corbits/error-sink": "workspace:*", - "@corbits/workflows": "workspace:*", - "@intx/crypto": "0.3.0", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "0.3.0", - "@intx/hub-sessions": "workspace:*", - "@intx/log": "0.3.0", - "@intx/types": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/workflows": { "name": "@corbits/workflows", "version": "0.0.1", @@ -817,8 +784,6 @@ "@corbits/codex-provider": ["@corbits/codex-provider@github:corbitsdev/corbits-codex-provider#a1f475f", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-codex-provider-a1f475f", "sha512-D6d0lblWx/4KudfLwQoQGrLB8GKRLP/HVp1cx08L2/hOGZAgH6txLPpeEtBqjl+R520qXSSPfM0kDC+3XNaAfw=="], - "@corbits/collections": ["@corbits/collections@workspace:packages/collections"], - "@corbits/credential-header": ["@corbits/credential-header@github:corbitsdev/corbits-credential-header#eee7b7e", { "peerDependencies": { "@intx/harness": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-credential-header-eee7b7e", "sha512-jCgdLZDxHSqL7PJo/xwp/xTCXTBdBgTQb4ZMBwRYCS6x29j8EPtuQiX0mvzZPRXVi02cmz45Shbc2/xQsjZ1oQ=="], "@corbits/credential-mcp": ["@corbits/credential-mcp@github:corbitsdev/corbits-credential-mcp#fb9ec3a", { "peerDependencies": { "@intx/harness": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-credential-mcp-fb9ec3a", "sha512-Co4jR+L3pfBbVYY7I6jRftoHeSb5+LCxzEEaAhVLYA3aY2PrPHZV37+DWxj3fcK/KdzboqbT3XSnqU8DyHRU9Q=="], @@ -849,7 +814,7 @@ "@corbits/url-path": ["@corbits/url-path@workspace:packages/url-path"], - "@corbits/webhook-triggers": ["@corbits/webhook-triggers@workspace:packages/webhook-triggers"], + "@corbits/webhooks": ["@corbits/webhooks@github:corbitsdev/webhooks#570bd52", { "dependencies": { "@intx/crypto": "^0.3.0", "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/mime": "^0.3.0", "@intx/types": "^0.3.0", "hono": "4.11.9" } }, "corbitsdev-webhooks-570bd52", "sha512-GWduublrOUDgiH20a5VHFcI/zcf4mcFlQL9IEesyXKXJLekqpLFbacs4V490h0jl0oVG9KQ5dVb+CjRfTcajZA=="], "@corbits/workflow-authoring-tools": ["@corbits/workflow-authoring-tools@workspace:tools/workflow-authoring"], diff --git a/package.json b/package.json index da92fe9112..c393dcdfb6 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,7 @@ "oxlint": "1.83.0", "oxfmt": "0.68.0", "puppeteer-core": "^25.7.0", - "typescript": "6.0.3", - "@corbits/webhook-triggers": "workspace:*" + "typescript": "6.0.3" }, "engines": { "bun": ">=1.2.0" diff --git a/packages/collections/LICENSE b/packages/collections/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/packages/collections/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/collections/README.md b/packages/collections/README.md deleted file mode 100644 index 0b20c84ea5..0000000000 --- a/packages/collections/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# @corbits/collections - -Bounded, self-evicting in-memory collections for state that would -otherwise grow for the lifetime of the process that holds it. - -## `createExpiringMap` - -A `Map`-like store where every entry carries a TTL from the moment it is -set. `get` drops an expired entry lazily on read; `set` opportunistically -sweeps every expired entry once per TTL window, so memory tracks recent -activity rather than the lifetime count of distinct keys ever seen — -without a timer to leak or `unref`. - -```ts -import { createExpiringMap } from "@corbits/collections"; - -const lastSeenByUser = createExpiringMap({ ttlMs: 10_000 }); -lastSeenByUser.set(userId, Date.now()); -lastSeenByUser.get(userId); // undefined once ttlMs has elapsed -``` - -This is the first primitive in the package. `apps/hub/src/launch-caches.ts`'s -`BoundedCache` (size-capped LRU, no TTL) is a sibling that predates this -package. `@corbits/chat`'s `createCryptoProviderCache` now consumes -`createExpiringMap` directly; `apps/hub/src/launch-caches.ts` is expected -to either do the same or contribute the size-capped-LRU shape here so -`BoundedCache` can retire in favor of one place for this problem. diff --git a/packages/collections/package.json b/packages/collections/package.json deleted file mode 100644 index a536baa562..0000000000 --- a/packages/collections/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@corbits/collections", - "version": "0.0.1", - "private": true, - "description": "Bounded, self-evicting in-memory collections for process-lifetime state — a TTL map today, with orchestrator/crypto-cache callers () expected to consolidate onto it rather than growing their own", - "license": "LGPL-2.1-only", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/collections/src/expiring-map.test.ts b/packages/collections/src/expiring-map.test.ts deleted file mode 100644 index 2e5ef1011a..0000000000 --- a/packages/collections/src/expiring-map.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createExpiringMap } from "./expiring-map"; - -/** A controllable clock: advances only when the test tells it to, so - * sweep and expiry timing is asserted exactly rather than raced against - * a real timer. */ -function fakeClock(startAt = 0) { - let now = startAt; - return { - now: () => now, - advance(ms: number) { - now += ms; - }, - }; -} - -describe("createExpiringMap", () => { - test("returns a value before its ttl elapses", () => { - const clock = fakeClock(); - const map = createExpiringMap({ - ttlMs: 1_000, - now: clock.now, - }); - - map.set("a", 1); - clock.advance(999); - - expect(map.get("a")).toBe(1); - }); - - test("drops a value once its ttl elapses, without a further sweep or set", () => { - const clock = fakeClock(); - const map = createExpiringMap({ - ttlMs: 1_000, - now: clock.now, - }); - - map.set("a", 1); - clock.advance(1_000); - - expect(map.get("a")).toBeUndefined(); - }); - - test("size reflects the lazy expiry a get() just performed", () => { - const clock = fakeClock(); - const map = createExpiringMap({ - ttlMs: 1_000, - now: clock.now, - }); - - map.set("a", 1); - clock.advance(1_000); - expect(map.get("a")).toBeUndefined(); - - expect(map.size).toBe(0); - }); - - test("a fresh set on an existing key resets its ttl", () => { - const clock = fakeClock(); - const map = createExpiringMap({ - ttlMs: 1_000, - now: clock.now, - }); - - map.set("a", 1); - clock.advance(600); - map.set("a", 2); - clock.advance(600); - - // 1200ms since the first set, but only 600ms since the refresh. - expect(map.get("a")).toBe(2); - }); - - test("size never counts an expired entry, even if nothing has read or swept it yet", () => { - const clock = fakeClock(); - const map = createExpiringMap({ - ttlMs: 1_000, - now: clock.now, - }); - - map.set("a", 1); - clock.advance(5_000); - - // Neither get() nor a further set() has touched "a" since it - // expired — size on its own must still report it as gone. - expect(map.size).toBe(0); - }); - - test("delete removes a key outright", () => { - const map = createExpiringMap({ ttlMs: 1_000 }); - map.set("a", 1); - - expect(map.delete("a")).toBe(true); - expect(map.get("a")).toBeUndefined(); - expect(map.delete("a")).toBe(false); - }); - - test("a set-triggered sweep clears every expired key, not just the one being set", () => { - const clock = fakeClock(); - const map = createExpiringMap({ - ttlMs: 1_000, - now: clock.now, - }); - - map.set("a", 1); - map.set("b", 2); - clock.advance(1_000); - // Neither "a" nor "b" has been read since expiring, so nothing has - // lazily dropped them yet — only the sweep this set triggers does. - map.set("c", 3); - - expect(map.size).toBe(1); - expect(map.get("c")).toBe(3); - }); - - test("a set before the sweep interval elapses does not sweep other expired entries early", () => { - const clock = fakeClock(); - const map = createExpiringMap({ - ttlMs: 1_000, - now: clock.now, - }); - - map.set("a", 1); - clock.advance(1_000); - map.set("b", 2); - // The sweep triggered by setting "b" cleared "a"; "b" itself is - // fresh and must survive a set that happens well within its own ttl. - clock.advance(1); - map.set("c", 3); - - expect(map.get("b")).toBe(2); - expect(map.get("c")).toBe(3); - }); - - test("many distinct keys within one ttl window do not grow past the window's own traffic once it passes", () => { - const clock = fakeClock(); - const map = createExpiringMap({ - ttlMs: 1_000, - now: clock.now, - }); - - for (let i = 0; i < 500; i += 1) map.set(`user_${i}`, i); - expect(map.size).toBe(500); - - clock.advance(1_000); - // A single new key's set sweeps the whole prior window away. - map.set("user_new", 1); - - expect(map.size).toBe(1); - }); -}); diff --git a/packages/collections/src/expiring-map.ts b/packages/collections/src/expiring-map.ts deleted file mode 100644 index 040c56ed69..0000000000 --- a/packages/collections/src/expiring-map.ts +++ /dev/null @@ -1,66 +0,0 @@ -// A TTL-eviction map for process-lifetime state whose only invariant is -// "how recently was this key touched" — a rate limiter, a dedupe guard, -// anything where an entry older than its own TTL is worthless and safe -// to forget. `get` drops an expired entry lazily on read; -// `set` opportunistically sweeps every expired entry once per TTL -// window, amortizing the cost of a full pass rather than checking every -// key on every call. There is no background timer: nothing to `unref`, -// nothing to leak if the map itself is dropped, and a test can drive it -// entirely with a fake clock. -export type ExpiringMap = { - get(key: K): V | undefined; - set(key: K, value: V): void; - delete(key: K): boolean; - /** The count of live entries as of this read — always sweeps first, - * since a stale expired-but-unswept count would defeat the point of - * exposing size at all (a caller reading it as a memory/cardinality - * signal). */ - readonly size: number; -}; - -type Entry = { value: V; expiresAt: number }; - -export function createExpiringMap(options: { - readonly ttlMs: number; - readonly now?: () => number; -}): ExpiringMap { - const now = options.now ?? Date.now; - const entries = new Map>(); - let lastSweptAt = now(); - - function isExpired(entry: Entry, at: number): boolean { - return at >= entry.expiresAt; - } - - function sweep(at: number): void { - lastSweptAt = at; - for (const [key, entry] of entries) { - if (isExpired(entry, at)) entries.delete(key); - } - } - - return { - get(key) { - const entry = entries.get(key); - if (entry === undefined) return undefined; - const at = now(); - if (isExpired(entry, at)) { - entries.delete(key); - return undefined; - } - return entry.value; - }, - set(key, value) { - const at = now(); - if (at - lastSweptAt >= options.ttlMs) sweep(at); - entries.set(key, { value, expiresAt: at + options.ttlMs }); - }, - delete(key) { - return entries.delete(key); - }, - get size() { - sweep(now()); - return entries.size; - }, - }; -} diff --git a/packages/collections/src/index.ts b/packages/collections/src/index.ts deleted file mode 100644 index 93aea5e780..0000000000 --- a/packages/collections/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createExpiringMap, type ExpiringMap } from "./expiring-map"; diff --git a/packages/collections/tsconfig.json b/packages/collections/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/packages/collections/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/packages/collections/tsconfig.src.json b/packages/collections/tsconfig.src.json deleted file mode 100644 index 2e5c99c497..0000000000 --- a/packages/collections/tsconfig.src.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "lib": ["ESNext"], - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/packages/webhook-triggers/LICENSE b/packages/webhook-triggers/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/packages/webhook-triggers/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/webhook-triggers/README.md b/packages/webhook-triggers/README.md deleted file mode 100644 index 6be63e6c90..0000000000 --- a/packages/webhook-triggers/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# @corbits/webhook-triggers - -Webhook-triggered workflow launches: an external service (e.g. Granola) -posts a payload to a per-trigger URL, the payload is verified against an -HMAC signature and mapped through a stored `{{path.to.field}}` template -into a workflow's input, and the resulting run launches through -Interchange's `prepareProvisionedDeployment` — the same provisioned -front chat's invite flow uses. - -## Composition with @intx/* - -Launching renders agent-runtime source onto the definition asset, then -calls Interchange's `workflowAllocationService.prepareProvisionedDeployment` -and delivers the opening message with `sessionService.sendUserMessage`. -Routes are built on `@intx/hub-api`'s `TenantEnv`/`requireGrant` and -`@intx/hub-sessions`; `@intx/db` provides the platform drizzle handle and -schema (`tenant`, `workflowDefinition`); `@intx/hub-common` generates ids; -`@intx/log` logs. The signing secret is encrypted at rest through -Interchange's `CredentialCipher` seam (`@intx/types`, dev dependency -`@intx/crypto` for tests). - -## Key modules - -- `src/schema.ts` — the one product table, `webhook_trigger`, in its own - `webhook_triggers` Postgres schema; the signing secret is stored - encrypted. -- `src/signature.ts` — `generateWebhookSecret`/`signPayload`/`verifySignature`: - the HMAC-SHA256 trust boundary an inbound delivery must pass before - anything else runs. -- `src/mapping.ts` — `renderInputTemplate`: fills a trigger's stored - template against the parsed payload; a missing field degrades to an - empty string rather than rejecting the delivery. -- `src/launch.ts` — `launchWebhookTrigger`: launches the run and hardens - the opening-mail send so a delivery already accepted (202) never throws - and never double-fires on a sender's retry. -- `src/ingress-routes.ts` — `createWebhookIngressRoutes`: the public, - unauthenticated delivery endpoint a webhook sender posts to. -- `src/management-routes.ts` — `createWebhookTriggerRoutes`: tenant-session - CRUD for creating/rotating/listing triggers. -- `src/migrations.ts` — this package's own `webhook_triggers_migrations` - ledger. - -## Running tests - -``` -cd packages/webhook-triggers && bun test -``` - -`test/store.drizzle.test.ts` and `test/migrations.test.ts` need a live -Postgres: `DATABASE_URL=postgres://localhost:5432/workbench_e2e`. diff --git a/packages/webhook-triggers/package.json b/packages/webhook-triggers/package.json deleted file mode 100644 index 7631b376a6..0000000000 --- a/packages/webhook-triggers/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@corbits/webhook-triggers", - "version": "0.0.1", - "private": true, - "description": "Webhook-triggered workflow launches: an external service (e.g. Granola) posts a payload that maps to input for an existing tenant workflow definition, launched through Interchange's prepareProvisionedDeployment.", - "license": "LGPL-2.1-only", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./migrations": "./src/migrations.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/collections": "workspace:*", - "@corbits/error-sink": "workspace:*", - "@corbits/workflows": "workspace:*", - "@intx/crypto": "0.3.0", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "0.3.0", - "@intx/hub-sessions": "workspace:*", - "@intx/log": "0.3.0", - "@intx/types": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/webhook-triggers/src/crypto-cache.test.ts b/packages/webhook-triggers/src/crypto-cache.test.ts deleted file mode 100644 index c96ec11e78..0000000000 --- a/packages/webhook-triggers/src/crypto-cache.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createCryptoProviderCache } from "./crypto-cache"; - -/** A controllable clock: advances only when the test tells it to, so - * eviction timing is asserted exactly rather than raced against a real - * timer. */ -function fakeClock(startAt = 0) { - let now = startAt; - return { - now: () => now, - advance(ms: number) { - now += ms; - }, - }; -} - -describe("createCryptoProviderCache", () => { - test("reuses the same provider for a key accessed within its ttl", async () => { - const clock = fakeClock(); - const cache = createCryptoProviderCache({ ttlMs: 1_000, now: clock.now }); - - const first = await cache.get("workbench-1"); - clock.advance(999); - const second = await cache.get("workbench-1"); - - expect(second).toBe(first); - }); - - test("mints a fresh provider once a key has gone untouched past its ttl", async () => { - const clock = fakeClock(); - const cache = createCryptoProviderCache({ ttlMs: 1_000, now: clock.now }); - - const first = await cache.get("workbench-1"); - clock.advance(1_000); - const second = await cache.get("workbench-1"); - - expect(second).not.toBe(first); - expect(second.getPublicKey()).not.toEqual(first.getPublicKey()); - }); - - test("an access refreshes the ttl, so a key in steady use never expires", async () => { - const clock = fakeClock(); - const cache = createCryptoProviderCache({ ttlMs: 1_000, now: clock.now }); - - const first = await cache.get("workbench-1"); - clock.advance(600); - await cache.get("workbench-1"); // refreshes the ttl - clock.advance(600); - const third = await cache.get("workbench-1"); - - // 1200ms since the first access, but only 600ms since the refresh. - expect(third).toBe(first); - }); - - test("distinct keys mint distinct providers", async () => { - const cache = createCryptoProviderCache(); - - const a = await cache.get("workbench-1"); - const b = await cache.get("workbench-2"); - - expect(a).not.toBe(b); - expect(a.getPublicKey()).not.toEqual(b.getPublicKey()); - }); - - test("independent caches mint different providers for the same key", async () => { - const first = createCryptoProviderCache(); - const second = createCryptoProviderCache(); - - const a = await first.get("run_same"); - const b = await second.get("run_same"); - - expect(a).not.toBe(b); - expect(a.getPublicKey()).not.toEqual(b.getPublicKey()); - }); - - test("one shared cache returns the same provider to every consumer of a key", async () => { - const cache = createCryptoProviderCache(); - - const chat = await cache.get("run_same"); - const webhook = await cache.get("run_same"); - const routine = await cache.get("run_same"); - const drafting = await cache.get("run_same"); - - expect(webhook).toBe(chat); - expect(routine).toBe(chat); - expect(drafting).toBe(chat); - }); -}); diff --git a/packages/webhook-triggers/src/crypto-cache.ts b/packages/webhook-triggers/src/crypto-cache.ts deleted file mode 100644 index 3d6a785fc8..0000000000 --- a/packages/webhook-triggers/src/crypto-cache.ts +++ /dev/null @@ -1,36 +0,0 @@ -// One `CryptoProvider` per cache key, minted once and reused while the -// key stays active. A caller picks its own key (a workbench id, a run -// id, ...); this module knows nothing about what the key means. -import { createEd25519Crypto, generateKeyPair } from "@intx/crypto"; -import { createExpiringMap } from "@corbits/collections"; -import type { CryptoProvider } from "@intx/types/runtime"; - -export type CryptoProviderCache = { - get(key: string): Promise; -}; - -/** A key untouched for this long is treated as gone rather than idle. */ -const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; - -export function createCryptoProviderCache(options?: { - readonly ttlMs?: number; - readonly now?: () => number; -}): CryptoProviderCache { - const providers = createExpiringMap>({ - ttlMs: options?.ttlMs ?? DEFAULT_TTL_MS, - ...(options?.now !== undefined ? { now: options.now } : {}), - }); - - return { - get(key: string): Promise { - const pending = providers.get(key); - if (pending !== undefined) { - providers.set(key, pending); - return pending; - } - const minted = generateKeyPair().then((keyPair) => createEd25519Crypto(keyPair)); - providers.set(key, minted); - return minted; - }, - }; -} diff --git a/packages/webhook-triggers/src/index.ts b/packages/webhook-triggers/src/index.ts deleted file mode 100644 index 7b3138816d..0000000000 --- a/packages/webhook-triggers/src/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -export { applyWebhookTriggersMigrations, webhookTriggersMigrationSql } from "./migrations"; -export { webhookTrigger, type WebhookTriggerRow } from "./schema"; -export { - createDrizzleWebhookTriggerStore, - type CreateWebhookTriggerInput, - type WebhookTriggerStore, - type WebhookTriggersDb, -} from "./store"; -export { - generateWebhookSecret, - signPayload, - verifySignature, - WEBHOOK_SIGNATURE_HEADER, - WEBHOOK_TIMESTAMP_HEADER, -} from "./signature"; -export { renderInputTemplate } from "./mapping"; -export { - launchWebhookTrigger, - type LaunchWebhookTriggerDeps, - type LaunchedWebhookTrigger, -} from "./launch"; -export { createWebhookIngressRoutes, type CreateWebhookIngressRoutesDeps } from "./ingress-routes"; -export { - createWebhookTriggerRoutes, - type CreateWebhookTriggerRoutesDeps, -} from "./management-routes"; -export { createCryptoProviderCache, type CryptoProviderCache } from "./crypto-cache"; diff --git a/packages/webhook-triggers/src/ingress-routes.ts b/packages/webhook-triggers/src/ingress-routes.ts deleted file mode 100644 index d32480b817..0000000000 --- a/packages/webhook-triggers/src/ingress-routes.ts +++ /dev/null @@ -1,110 +0,0 @@ -// The external-facing half of this package: a service outside -// Workbench's control (Granola, or anything else) posts here to kick -// off a workflow run. This is THE trust boundary — no session cookie, -// no tenant membership, nothing about the caller is trusted except -// what the HMAC signature over `timestamp.rawBody` proves, and proves -// only within the freshness window `verifySignature` enforces (see -// `./signature.ts`) — so a captured, byte-for-byte replay of a real -// delivery stops verifying once it goes stale. Every failure mode -// here is loud and specific in the log, but every one of unknown -// trigger, disabled trigger, and bad/missing/stale signature returns -// the SAME generic 401 `unauthorized` response, so a probe against a -// wrong triggerId cannot distinguish "no such trigger" from "disabled" -// from "wrong secret" from "replayed". -import { Hono } from "hono"; -import type { Env } from "hono"; -import { getLogger } from "@intx/log"; - -import type { LaunchedWebhookTrigger } from "./launch"; -import { - WEBHOOK_SIGNATURE_HEADER, - WEBHOOK_TIMESTAMP_HEADER, - isFreshTimestamp, - verifySignature, -} from "./signature"; -import type { WebhookTriggerRow } from "./schema"; -import type { WebhookTriggerStore } from "./store"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -const log = getLogger(["webhook-triggers", "ingress"]); - -const unauthorizedResponse = () => - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "invalid or missing signature", - }); - -export type CreateWebhookIngressRoutesDeps = { - store: WebhookTriggerStore; - /** - * The hub composes this as `(trigger, payload) => - * launchWebhookTrigger(deps, trigger, payload)`, closing over the - * real Interchange launch deps — kept as a seam here (rather than - * this module importing `launchWebhookTrigger` directly) so the - * route's own parsing/signature/lookup logic is testable without a - * database or the launch machinery. - */ - launch: (trigger: WebhookTriggerRow, payload: unknown) => Promise; -}; - -/** - * Mounts `POST /:triggerId`, the single ingress endpoint. Intended to - * be mounted OUTSIDE the platform's `resolveTenant` middleware — this - * route resolves its own tenant scope by looking the trigger up by - * id, since the caller carries no session and no tenant path segment. - */ -export function createWebhookIngressRoutes(deps: CreateWebhookIngressRoutesDeps): Hono { - const app = new Hono(); - - app.post("/:triggerId", async (c) => { - const triggerId = c.req.param("triggerId"); - const trigger = await deps.store.getById(triggerId); - if (trigger === undefined) { - log.info("Webhook delivery for unknown trigger {triggerId}", { - triggerId, - }); - return c.json(unauthorizedResponse(), 401); - } - - if (!trigger.enabled) { - log.info("Webhook delivery for disabled trigger {triggerId}", { - triggerId, - }); - return c.json(unauthorizedResponse(), 401); - } - - const rawBody = await c.req.text(); - const signatureHeader = c.req.header(WEBHOOK_SIGNATURE_HEADER); - const timestampHeader = c.req.header(WEBHOOK_TIMESTAMP_HEADER); - if (!verifySignature(trigger.secret, timestampHeader, rawBody, signatureHeader)) { - const reason = isFreshTimestamp(timestampHeader) - ? "bad signature" - : "missing or stale timestamp"; - log.warn("Rejected webhook delivery for trigger {triggerId}: {reason}", { - triggerId, - reason, - }); - return c.json(unauthorizedResponse(), 401); - } - - let payload: unknown; - try { - payload = rawBody === "" ? {} : JSON.parse(rawBody); - } catch { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "payload is not valid JSON", - }), - 400, - ); - } - - const launched = await deps.launch(trigger, payload); - await deps.store.recordFired(trigger.id, new Date()); - - return c.json({ instanceId: launched.instanceId, address: launched.triggerAddress }, 202); - }); - - return app; -} diff --git a/packages/webhook-triggers/src/launch.ts b/packages/webhook-triggers/src/launch.ts deleted file mode 100644 index fef295251e..0000000000 --- a/packages/webhook-triggers/src/launch.ts +++ /dev/null @@ -1,229 +0,0 @@ -// Launches a workflow run from a verified webhook delivery through -// Interchange's `prepareProvisionedDeployment` — the same provisioned -// front chat's `provisionOnAsset` already drives. This package resolves -// the definition asset HEAD and asks Interchange to provision that -// existing source; it does not mint the run row itself. -// -// Opening mail is one signed `SidecarRouter.routeMail` delivery. Launch is -// async: mail sent before ready is queued. A delivery already accepted -// (202) has already committed a real run by the time this send runs, so -// a failed mail must not throw past `createWebhookIngressRoutes` — that -// would both hide the run (no `store.recordFired` call) and, if the -// sender's webhook client retries the same delivery on a 5xx, mint a -// second, duplicate run for one event. On send failure this only -// reports through `@corbits/error-sink`, naming the run. -import { reportError } from "@corbits/error-sink"; -import { - deliverWhenRoutable, - recordAgentSessionAtProvision, - WORKFLOW_SOURCE_ENTRY, - type EventCollectorPort, -} from "@corbits/workflows"; -import { listVisibleOfferings, type DB } from "@intx/db"; -import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; -import { generateId } from "@intx/hub-common"; -import { - assembleMessage, - assembleSignedContent, - createDetachedSignatureFromProvider, - type MessageHeaders, -} from "@intx/mime"; -import { base64Encode, type CredentialCipher } from "@intx/types"; -import { - DEFAULT_ASSET_REF, - type RepoStore, - type SidecarRouter, - type WorkflowAllocationService, -} from "@intx/hub-sessions"; -import { and, eq } from "drizzle-orm"; - -import { renderInputTemplate } from "./mapping"; -import type { WebhookTriggerRow } from "./schema"; - -import type { CryptoProviderCache } from "./crypto-cache"; - -export type LaunchWebhookTriggerDeps = { - db: DB["db"]; - repoStore: Pick; - workflowAllocationService: Pick; - sidecarRouter: Pick; - /** - * The same wrapped `EventCollectorRegistry` every native launcher - * threads through (`apps/hub/src/index.ts`'s `eventCollectors`) — this - * launcher records the run's `agent_session` and event collector the - * instant `prepareProvisionedDeployment` returns. - */ - eventCollectors: Pick; - /** - * Reads the hub's live sidecar routing table. A freshly provisioned - * run's sidecar takes several seconds to boot and register - * — a webhook's opening mail can land in that gap and fail with - * "agent is unreachable" even though the run itself deployed fine. - * `deliverWhenRoutable` polls this until the address is routable - * before its one retry. - */ - isRoutable: (address: string) => boolean; - cryptoProviderCache: CryptoProviderCache; - /** - * Host-supplied cipher. Interchange decrypts bindings inside - * `prepareProvisionedDeployment`; this port still owns the field so - * the composition root can keep passing the cipher it tagged at boot. - */ - credentialCipher: CredentialCipher; -}; - -export type LaunchedWebhookTrigger = { - readonly instanceId: string; - readonly triggerAddress: string; -}; - -/** - * Resolves the trigger's referenced workflow definition (must be - * deployed and materialized), provisions its existing asset HEAD - * through Interchange, then delivers the rendered input mapping as - * the run's first inbound message. The webhook sender itself is never - * a principal on the platform, so the mail's `from` names the trigger, - * not a person. - */ -export async function launchWebhookTrigger( - deps: LaunchWebhookTriggerDeps, - trigger: WebhookTriggerRow, - payload: unknown, -): Promise { - const definitionRow = await deps.db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, trigger.workflowDefinitionId), - eq(workflowDefinition.tenantId, trigger.tenantId), - ), - }); - if (definitionRow === undefined) { - throw new Error( - `webhook trigger "${trigger.id}" names no workflow definition ` + - `"${trigger.workflowDefinitionId}" for its tenant`, - ); - } - if (definitionRow.status !== "deployed") { - throw new Error( - `workflow definition "${trigger.workflowDefinitionId}" is not in a ` + - `launchable state (status: ${definitionRow.status})`, - ); - } - if (definitionRow.assetId === null) { - throw new Error( - `workflow definition "${trigger.workflowDefinitionId}" has not been ` + "materialized", - ); - } - - const tenantRow = await deps.db.query.tenant.findFirst({ - where: eq(tenantTable.id, trigger.tenantId), - }); - if (tenantRow === undefined) { - throw new Error(`no tenant "${trigger.tenantId}"`); - } - - const offerings = [...(await listVisibleOfferings(deps.db, trigger.tenantId))].sort( - (a, b) => a.offering.priority - b.offering.priority, - ); - const sourceOfferingIds = offerings.map((o) => o.offering.id); - const defaultSourceOfferingId = sourceOfferingIds[0]; - if (defaultSourceOfferingId === undefined) { - throw new Error(`no catalog offerings visible to tenant "${trigger.tenantId}"`); - } - - const assetId = definitionRow.assetId; - const commitSha = await deps.repoStore.resolveRef( - { kind: "hub" }, - { kind: "workflow", id: assetId }, - DEFAULT_ASSET_REF, - ); - if (commitSha === null) { - throw new Error(`definition asset "${assetId}" has no HEAD`); - } - const anchorRunId = generateId("workflowRun"); - const sessionId = generateId("session"); - - const prepared = await deps.workflowAllocationService.prepareProvisionedDeployment({ - tenantId: trigger.tenantId, - anchorRunId, - sessionId, - deploymentDomain: tenantRow.domain, - source: { - kind: "asset", - assetId, - package: { format: "source", commitSha }, - }, - entry: WORKFLOW_SOURCE_ENTRY, - definitionAssetId: assetId, - sourceAuthorityPrincipalId: trigger.createdBy, - sourceOfferingIds, - defaultSourceOfferingId, - deployContent: { systemPrompt: "" }, - }); - - await recordAgentSessionAtProvision({ - db: deps.db, - eventCollectors: deps.eventCollectors, - runId: prepared.anchorRunId, - sessionId, - sourceAuthorityPrincipalId: trigger.createdBy, - }); - - const content = renderInputTemplate(trigger.inputTemplate, payload); - const cryptoProvider = await deps.cryptoProviderCache.get(prepared.anchorRunId); - try { - await deliverWhenRoutable({ - send: async () => { - const from = `webhook-trigger:${trigger.id}`; - const messageId = `<${crypto.randomUUID()}@${tenantRow.domain}>`; - const headers: MessageHeaders = { - from, - to: [prepared.deploymentAddress], - cc: undefined, - date: new Date(), - messageId, - subject: undefined, - inReplyTo: undefined, - references: undefined, - mimeVersion: "1.0", - interchangeType: "conversation.message", - interchangeCorrelationId: undefined, - interchangeTenantId: trigger.tenantId, - interchangeAgentId: undefined, - interchangeSessionId: sessionId, - interchangeOfferingId: undefined, - interchangeSchemaVersion: undefined, - traceparent: undefined, - tracestate: undefined, - }; - const signedContent = assembleSignedContent({ kind: "conversation", text: content }); - const signature = await createDetachedSignatureFromProvider(signedContent, cryptoProvider); - const rawMessage = assembleMessage(headers, signedContent, signature); - const delivered = deps.sidecarRouter.routeMail( - prepared.deploymentAddress, - base64Encode(rawMessage), - from, - messageId, - ); - if (!delivered) { - throw new Error(`agent is unreachable: ${prepared.deploymentAddress} is not routable`); - } - }, - isRoutable: () => deps.isRoutable(prepared.deploymentAddress), - }); - } catch (error) { - reportError(error, { - operation: "webhookTriggers.launch.deliverInput", - tenantId: trigger.tenantId, - agentId: prepared.deploymentAddress, - extra: { - instanceId: prepared.anchorRunId, - triggerId: trigger.id, - }, - }); - } - - return { - instanceId: prepared.anchorRunId, - triggerAddress: prepared.deploymentAddress, - }; -} diff --git a/packages/webhook-triggers/src/management-routes.ts b/packages/webhook-triggers/src/management-routes.ts deleted file mode 100644 index 11815ae44e..0000000000 --- a/packages/webhook-triggers/src/management-routes.ts +++ /dev/null @@ -1,229 +0,0 @@ -// Tenant-scoped CRUD over triggers, mounted inside the platform's -// native tenant middleware (`TenantEnv`'s `tenant`/`principal` are -// resolved before any handler here runs, the same way -// `@corbits/chat`'s `routes.ts` relies on it). This is a management -// surface, not the trust boundary — that is `./ingress-routes.ts`, -// mounted separately and unauthenticated by design. -// -// The secret is generated here (server-side, `crypto.randomBytes` via -// `./signature.ts`) and returned exactly once: in the create response -// and in the rotate response. Every other response — get, list — omits -// it entirely, never even a redacted form. -import { Hono } from "hono"; -import { type } from "arktype"; - -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import { idResource } from "@intx/hub-api"; -import { generateId } from "@intx/hub-common"; -import { pgErrorCode, PG_UNIQUE_VIOLATION } from "@intx/db"; - -import { generateWebhookSecret } from "./signature"; -import type { WebhookTriggerRow } from "./schema"; -import type { WebhookTriggerStore } from "./store"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -/** - * True for a Postgres unique-violation (`23505`) — the shape a duplicate - * `(tenant, workflow definition, name)` now raises through - * `0003_webhook_trigger_tenant_definition_name_unique`. `pgErrorCode` - * walks Drizzle's wrapped cause chain, since a real insert failure - * arrives as a `DrizzleQueryError` rather than the raw driver error; - * this package's in-memory test fake stamps `.code` directly to match. - * Never silently retried as an `ensure`: this route's `create` promises - * a genuinely new row, so a collision is reported to the caller as a - * conflict rather than handed back someone else's trigger. - */ -function isUniqueViolation(error: unknown): boolean { - return pgErrorCode(error) === PG_UNIQUE_VIOLATION; -} - -const CreateTriggerBody = type({ - name: "string", - workflowDefinitionId: "string", - inputTemplate: "string", -}); - -const SetEnabledBody = type({ - enabled: "boolean", -}); - -/** - * Every field of a trigger except its secret — the shape returned by - * list/get/enable/disable, and by create/rotate alongside a one-time - * `secret` field those two responses add. - */ -function publicView(row: WebhookTriggerRow) { - return { - id: row.id, - tenantId: row.tenantId, - name: row.name, - workflowDefinitionId: row.workflowDefinitionId, - inputTemplate: row.inputTemplate, - enabled: row.enabled, - createdBy: row.createdBy, - createdAt: row.createdAt.toISOString(), - lastFiredAt: row.lastFiredAt?.toISOString() ?? null, - }; -} - -export type CreateWebhookTriggerRoutesDeps = { - store: WebhookTriggerStore; - requireGrant: RequireGrant; - /** - * When provided, `POST /` rejects with 404 if the workflow definition - * is not in the request tenant. Tests may omit (always-allow). - */ - workflowDefinitionInTenant?: (tenantId: string, definitionId: string) => Promise; -}; - -export function createWebhookTriggerRoutes(deps: CreateWebhookTriggerRoutesDeps): Hono { - const app = new Hono(); - - app.post("/", deps.requireGrant("webhook-trigger:*", "create"), async (c) => { - const body = CreateTriggerBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid trigger body: ${body.summary}`, - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - - if (deps.workflowDefinitionInTenant !== undefined) { - const owned = await deps.workflowDefinitionInTenant(tenant.id, body.workflowDefinitionId); - if (!owned) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "definition not found", - }), - 404, - ); - } - } - - const secret = generateWebhookSecret(); - - let row: WebhookTriggerRow; - try { - row = await deps.store.create({ - id: generateId("workflowRun"), - tenantId: tenant.id, - name: body.name, - workflowDefinitionId: body.workflowDefinitionId, - inputTemplate: body.inputTemplate, - secret, - createdBy: principal.id, - }); - } catch (cause) { - if (isUniqueViolation(cause)) { - return c.json( - makeErrorEnvelope({ - code: "conflict", - userMessage: "a trigger with this name already exists for this workflow definition", - }), - 409, - ); - } - throw cause; - } - - return c.json({ ...publicView(row), secret }, 201); - }); - - app.get("/", deps.requireGrant("webhook-trigger:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const rows = await deps.store.list(tenant.id); - return c.json({ items: rows.map(publicView) }); - }); - - app.get("/:id", deps.requireGrant(idResource("webhook-trigger", "id"), "read"), async (c) => { - const tenant = c.get("tenant"); - const row = await deps.store.get(tenant.id, c.req.param("id")); - if (row === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "trigger not found", - }), - 404, - ); - } - return c.json(publicView(row)); - }); - - app.post( - "/:id/rotate-secret", - deps.requireGrant(idResource("webhook-trigger", "id"), "write"), - async (c) => { - const tenant = c.get("tenant"); - const secret = generateWebhookSecret(); - const row = await deps.store.rotateSecret(tenant.id, c.req.param("id"), secret); - if (row === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "trigger not found", - }), - 404, - ); - } - return c.json({ ...publicView(row), secret }); - }, - ); - - app.post( - "/:id/enabled", - deps.requireGrant(idResource("webhook-trigger", "id"), "write"), - async (c) => { - const body = SetEnabledBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid enabled body: ${body.summary}`, - }), - 400, - ); - } - const tenant = c.get("tenant"); - const row = await deps.store.setEnabled(tenant.id, c.req.param("id"), body.enabled); - if (row === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "trigger not found", - }), - 404, - ); - } - return c.json(publicView(row)); - }, - ); - - app.delete( - "/:id", - deps.requireGrant(idResource("webhook-trigger", "id"), "delete"), - async (c) => { - const tenant = c.get("tenant"); - const removed = await deps.store.remove(tenant.id, c.req.param("id")); - if (!removed) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "trigger not found", - }), - 404, - ); - } - return c.body(null, 204); - }, - ); - - return app; -} diff --git a/packages/webhook-triggers/src/mapping.ts b/packages/webhook-triggers/src/mapping.ts deleted file mode 100644 index 59588584d3..0000000000 --- a/packages/webhook-triggers/src/mapping.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Renders a trigger's stored `inputTemplate` against an already-parsed, -// already-typed-as-`unknown` webhook payload: `{{path.to.field}}` -// placeholders are replaced with the string form of the value at that -// dotted path, and any other placeholder resolves to an empty string -// rather than throwing — a webhook payload's shape is never a contract -// this package can enforce on the sender, so a missing field degrades -// the rendered message rather than rejecting the delivery outright. -const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g; - -function readPath(payload: unknown, dottedPath: string): unknown { - return dottedPath.split(".").reduce((value, segment) => { - if (value === null || typeof value !== "object") return undefined; - return (value as Record)[segment]; - }, payload); -} - -function stringify(value: unknown): string { - if (value === undefined || value === null) return ""; - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return JSON.stringify(value); -} - -/** - * Applies `template`'s `{{path.to.field}}` placeholders against - * `payload`, producing the message content sent to the launched run. - */ -export function renderInputTemplate(template: string, payload: unknown): string { - return template.replace(PLACEHOLDER, (_match, dottedPath: string) => - stringify(readPath(payload, dottedPath)), - ); -} diff --git a/packages/webhook-triggers/src/migrations.ts b/packages/webhook-triggers/src/migrations.ts deleted file mode 100644 index 0c4ac174e0..0000000000 --- a/packages/webhook-triggers/src/migrations.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Package-owned migration for @corbits/webhook-triggers's product -// tables. The platform's own schema is authored and applied by -// @intx/db (see apps/hub/src/migrate.ts); this module is the -// package's half of the install story — mount + migration is the -// entire install story, mirroring `@corbits/cron`'s `schema.ts`. -// -// One idempotent SQL block, `CREATE SCHEMA IF NOT EXISTS` plus -// `CREATE TABLE IF NOT EXISTS`/`CREATE INDEX IF NOT EXISTS` -// throughout, so no ledger table is needed to know what has run. A -// session-level advisory lock (`pg_advisory_xact_lock`, released -// automatically at transaction end) held around the whole statement -// block keeps two hub replicas booting at once from racing each -// other's DDL. -import postgres from "postgres"; - -export const webhookTriggersMigrationSql = ` - CREATE SCHEMA IF NOT EXISTS "webhook_triggers"; - - CREATE TABLE IF NOT EXISTS "webhook_triggers"."webhook_trigger" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL REFERENCES "public"."tenant" ("id") ON DELETE CASCADE, - "name" text NOT NULL, - "workflow_definition_id" text NOT NULL, - "input_template" text NOT NULL, - "secret" text NOT NULL, - "enabled" boolean NOT NULL DEFAULT true, - "created_by" text NOT NULL REFERENCES "public"."principal" ("id") ON DELETE CASCADE, - "created_at" timestamptz NOT NULL DEFAULT now(), - "last_fired_at" timestamptz - ); - - CREATE INDEX IF NOT EXISTS "webhook_trigger_tenant_id_idx" - ON "webhook_triggers"."webhook_trigger" ("tenant_id"); - - CREATE UNIQUE INDEX IF NOT EXISTS "webhook_trigger_tenant_definition_name_unique" - ON "webhook_triggers"."webhook_trigger" ("tenant_id", "workflow_definition_id", "name"); -`; - -/** - * Apply `webhookTriggersMigrationSql` against `databaseUrl`, idempotently - * (every statement is `IF NOT EXISTS`) and inside one advisory-locked - * transaction so concurrent hub replicas cannot race the same DDL. - */ -export async function applyWebhookTriggersMigrations(databaseUrl: string): Promise { - const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined }); - try { - await sql.begin(async (tx) => { - await tx.unsafe(`SELECT pg_advisory_xact_lock(hashtext('corbits_webhook_triggers'))`); - await tx.unsafe(webhookTriggersMigrationSql); - }); - } catch (error) { - throw new Error( - `@corbits/webhook-triggers migration failed: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } finally { - await sql.end({ timeout: 5 }); - } -} diff --git a/packages/webhook-triggers/src/schema.ts b/packages/webhook-triggers/src/schema.ts deleted file mode 100644 index 7640ca2caf..0000000000 --- a/packages/webhook-triggers/src/schema.ts +++ /dev/null @@ -1,54 +0,0 @@ -// The product table `@corbits/webhook-triggers` owns: a trigger row -// per external-webhook-to-workflow binding, living in this package's -// own `webhook_triggers` Postgres schema, fully siloed from the -// platform's `public` schema (see `./migrations.ts`). -// -// `tenant_id` and `created_by` (a real Interchange principal id — see -// `management-routes.ts`'s `createdBy: principal.id`) are hard foreign -// keys into Interchange's own `tenant`/`principal` tables: -// `hostTenant`/`hostPrincipal` below are declared, never migrated, -// just far enough to carry the FK, same pattern as -// `@corbits/artifacts`'s `src/schema.ts`. -// -// The signing secret is encrypted at rest via Interchange's -// `CredentialCipher` seam — see `./store.ts` for the encrypt/decrypt -// wiring and `./signature.ts` for the security-model note on what that -// does and does not close. -import { boolean, pgSchema, pgTable, text, timestamp } from "drizzle-orm/pg-core"; - -export const webhookTriggersSchema = pgSchema("webhook_triggers"); - -const hostTenant = pgTable("tenant", { id: text("id").primaryKey() }); -const hostPrincipal = pgTable("principal", { id: text("id").primaryKey() }); - -export const webhookTrigger = webhookTriggersSchema.table("webhook_trigger", { - id: text("id").primaryKey(), - tenantId: text("tenant_id") - .notNull() - .references(() => hostTenant.id, { onDelete: "cascade" }), - name: text("name").notNull(), - workflowDefinitionId: text("workflow_definition_id").notNull(), - /** - * A template applied to the parsed JSON payload to produce the - * message content the launched run receives — see - * `./mapping.ts:renderInputTemplate`. Stored as plain jsonb (a - * single string) rather than a structured mapping language, since - * `{{path.to.field}}` interpolation is the entire v1 need. - */ - inputTemplate: text("input_template").notNull(), - /** - * The HMAC-SHA256 secret verified against the inbound signature - * header, stored as a `CredentialCipher`-encrypted blob (see - * `./store.ts`) — never returned by any route after creation except - * a rotate response, and never in this encrypted form even then. - */ - secret: text("secret").notNull(), - enabled: boolean("enabled").notNull().default(true), - createdBy: text("created_by") - .notNull() - .references(() => hostPrincipal.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - lastFiredAt: timestamp("last_fired_at", { withTimezone: true }), -}); - -export type WebhookTriggerRow = typeof webhookTrigger.$inferSelect; diff --git a/packages/webhook-triggers/src/signature.ts b/packages/webhook-triggers/src/signature.ts deleted file mode 100644 index 095adbad76..0000000000 --- a/packages/webhook-triggers/src/signature.ts +++ /dev/null @@ -1,104 +0,0 @@ -// The trust boundary this package exists to guard: an inbound HTTP -// call from a network Workbench does not control. Every byte off the -// wire is untrusted until its signature verifies against the -// trigger's own secret, over a delivery recent enough to matter. -// -// Security-model note: the secret is generated server-side with -// `crypto.randomBytes` and shown to the caller exactly once, at -// creation or rotation. `store.ts` encrypts it at rest through -// Interchange's `CredentialCipher` seam (`@intx/types`), closing the -// "database dump discloses every signing secret" half of this -// tradeoff — but this function still needs the *raw* secret in process -// memory to recompute the HMAC, and `store.ts` decrypts it back to -// plaintext for exactly that purpose on every `get`/`getById`. That -// half of the tradeoff is inherent to HMAC verification (unlike a -// hashed password, the verifying side must be able to reproduce the -// MAC, not just compare against it) and is not something -// `CredentialCipher` — an at-rest seam — removes; closing it for real -// means moving to asymmetric signing (caller signs with a private key, -// this package verifies with a stored public key), a bigger v2 change. -// Flagged, not silently accepted. -// -// The other half of that same tradeoff — one shared secret, verified -// by recomputing the same MAC, with nothing to stop a captured -// request from being replayed forever — is closed here by binding an -// `X-Webhook-Timestamp` into the signed material instead of signing -// `rawBody` alone. `verifySignature` rejects a delivery whose -// timestamp falls outside a 5-minute window on either side of now — -// both a stale replay of an old capture and a timestamp forged far -// into the future — the same window Stripe's `timestamp.signature` -// scheme uses. This still doesn't stop a delivery replayed within -// that window; closing that fully needs a tracked, per-delivery nonce -// (a bigger v2 change, like the asymmetric-signing one above). -import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; - -export const WEBHOOK_SIGNATURE_HEADER = "x-webhook-signature"; -export const WEBHOOK_TIMESTAMP_HEADER = "x-webhook-timestamp"; - -const SECRET_BYTES = 32; -const TIMESTAMP_TOLERANCE_SECONDS = 300; - -/** A fresh, high-entropy secret for a new or rotated trigger. */ -export function generateWebhookSecret(): string { - return randomBytes(SECRET_BYTES).toString("hex"); -} - -function signedMaterial(timestamp: string, rawBody: string): string { - return `${timestamp}.${rawBody}`; -} - -/** The HMAC-SHA256 hex digest of `timestamp.rawBody` under `secret`. */ -export function signPayload(secret: string, timestamp: string, rawBody: string): string { - return createHmac("sha256", secret) - .update(signedMaterial(timestamp, rawBody), "utf8") - .digest("hex"); -} - -/** - * Exported only so `ingress-routes.ts` can log a more specific reason - * for a rejected delivery than "bad signature" — the HTTP response - * stays the same generic 401 either way (see the module doc comment). - */ -export function isFreshTimestamp(timestampHeader: string | undefined): timestampHeader is string { - if (timestampHeader === undefined || timestampHeader === "") return false; - const seconds = Number(timestampHeader); - if (!Number.isFinite(seconds)) return false; - return Math.abs(Date.now() / 1000 - seconds) <= TIMESTAMP_TOLERANCE_SECONDS; -} - -/** - * Verifies an inbound `X-Webhook-Signature` header, over - * `X-Webhook-Timestamp.rawBody`, against the trigger's stored secret — - * in constant time so a timing side-channel never leaks how many - * leading bytes of a guess were correct, and only for a timestamp - * within `TIMESTAMP_TOLERANCE_SECONDS` of now so a captured delivery - * stops verifying once it goes stale. `timingSafeEqual` throws on a - * length mismatch rather than returning false, so an obviously-wrong- - * length header is rejected explicitly before comparison, not left to - * throw past this function. - */ -export function verifySignature( - secret: string, - timestampHeader: string | undefined, - rawBody: string, - providedSignatureHex: string | undefined, -): boolean { - if (providedSignatureHex === undefined || providedSignatureHex === "") { - return false; - } - if (!isFreshTimestamp(timestampHeader)) { - return false; - } - const expected = signPayload(secret, timestampHeader, rawBody); - const expectedBuffer = Buffer.from(expected, "hex"); - let providedBuffer: Buffer; - try { - providedBuffer = Buffer.from(providedSignatureHex, "hex"); - } catch { - return false; - } - if (expectedBuffer.length !== providedBuffer.length || expectedBuffer.length === 0) { - return false; - } - return timingSafeEqual(expectedBuffer, providedBuffer); -} diff --git a/packages/webhook-triggers/src/store.ts b/packages/webhook-triggers/src/store.ts deleted file mode 100644 index 96e576a630..0000000000 --- a/packages/webhook-triggers/src/store.ts +++ /dev/null @@ -1,228 +0,0 @@ -// Persistence for the one webhook-triggers product table, kept apart -// from route wiring the same way `@corbits/chat`'s `store.ts` is: the -// HTTP layer never touches drizzle directly, and `WebhookTriggerStore` -// is the seam both route modules actually depend on, so each is -// testable with a plain in-memory fake. -// -// The signing secret is encrypted at rest through Interchange's -// `CredentialCipher` seam (`@intx/types`) — the same seam -// `vendor/intx/hub-api`'s credential/model-provider/oauth-client routes -// use. `createDrizzleWebhookTriggerStore` encrypts on `create` and -// `rotateSecret`, and decrypts on `get`/`getById`, so every other -// module in this package (`management-routes.ts`, `ingress-routes.ts`, -// their tests) keeps seeing a plaintext `secret` on a `WebhookTriggerRow` -// exactly as before — only what actually lands on disk changed. The -// `credentialAad` binding is `["credential-secret", triggerId, "secret"]`, -// so a ciphertext copied onto a different trigger row fails to decrypt -// rather than silently decrypting under the wrong identity. -import { and, eq } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { credentialAad, type CredentialCipher } from "@intx/types"; - -import { webhookTrigger, type WebhookTriggerRow } from "./schema"; - -export type WebhookTriggersDb = Record> = - PostgresJsDatabase; - -export interface CreateWebhookTriggerInput { - readonly id: string; - readonly tenantId: string; - readonly name: string; - readonly workflowDefinitionId: string; - readonly inputTemplate: string; - readonly secret: string; - readonly createdBy: string; -} - -export interface WebhookTriggerStore { - /** - * Always inserts a new row — a second call with the same - * `(tenantId, workflowDefinitionId, name)` throws a unique-constraint - * violation rather than silently reusing the first row. The generic - * management API (`management-routes.ts`) wants this: reusing an - * existing row here would mean a caller posting a *different* - * `inputTemplate` under a name that already exists gets back 201 and - * someone else's trigger. - */ - create(input: CreateWebhookTriggerInput): Promise; - /** - * Idempotent create: a second call with the same - * `(tenantId, workflowDefinitionId, name)` returns the first call's - * row untouched rather than inserting (or throwing) — the id and the - * real, already-persisted secret, never a freshly generated one that - * was never stored. This is what a retry-safe *mint* wants - * (`startReviewingRepos`'s trigger-per-repo convention): two - * concurrent calls for the same repo must settle on exactly one live - * trigger. - */ - ensure(input: CreateWebhookTriggerInput): Promise; - get(tenantId: string, triggerId: string): Promise; - /** Looked up by the ingress route, which has no tenant scope of its own. */ - getById(triggerId: string): Promise; - list(tenantId: string): Promise; - rotateSecret( - tenantId: string, - triggerId: string, - secret: string, - ): Promise; - setEnabled( - tenantId: string, - triggerId: string, - enabled: boolean, - ): Promise; - recordFired(triggerId: string, firedAt: Date): Promise; - remove(tenantId: string, triggerId: string): Promise; -} - -export function createDrizzleWebhookTriggerStore>( - db: WebhookTriggersDb, - credentialCipher: CredentialCipher, -): WebhookTriggerStore { - async function decrypted(row: WebhookTriggerRow): Promise { - const secret = await credentialCipher.decrypt(row.secret, credentialAad(row.id, "secret")); - return { ...row, secret }; - } - - return { - async create(input) { - const encryptedSecret = await credentialCipher.encrypt( - input.secret, - credentialAad(input.id, "secret"), - ); - const [row] = await db - .insert(webhookTrigger) - .values({ - id: input.id, - tenantId: input.tenantId, - name: input.name, - workflowDefinitionId: input.workflowDefinitionId, - inputTemplate: input.inputTemplate, - secret: encryptedSecret, - enabled: true, - createdBy: input.createdBy, - }) - .returning(); - if (row === undefined) { - throw new Error("webhook trigger insert returned no row"); - } - // The plaintext is already known here (the caller just generated - // it) — return it directly rather than round-tripping through an - // extra decrypt call. - return { ...row, secret: input.secret }; - }, - - async ensure(input) { - const encryptedSecret = await credentialCipher.encrypt( - input.secret, - credentialAad(input.id, "secret"), - ); - // Insert-first, not select-then-insert: two concurrent calls for - // the same (tenant, definition, name) both attempt the insert, - // the unique index serializes them, and the loser's empty - // `returning()` re-selects the winner's row rather than creating - // a duplicate trigger. - const inserted = await db - .insert(webhookTrigger) - .values({ - id: input.id, - tenantId: input.tenantId, - name: input.name, - workflowDefinitionId: input.workflowDefinitionId, - inputTemplate: input.inputTemplate, - secret: encryptedSecret, - enabled: true, - createdBy: input.createdBy, - }) - .onConflictDoNothing({ - target: [ - webhookTrigger.tenantId, - webhookTrigger.workflowDefinitionId, - webhookTrigger.name, - ], - }) - .returning(); - const row = inserted[0]; - if (row) { - // As in `create`, the plaintext is already known — return it - // directly rather than round-tripping through an extra decrypt. - return { ...row, secret: input.secret }; - } - const [existing] = await db - .select() - .from(webhookTrigger) - .where( - and( - eq(webhookTrigger.tenantId, input.tenantId), - eq(webhookTrigger.workflowDefinitionId, input.workflowDefinitionId), - eq(webhookTrigger.name, input.name), - ), - ) - .limit(1); - if (existing === undefined) { - throw new Error("expected webhook trigger row after conflicting insert"); - } - // The winner's real, already-persisted secret — never the - // freshly generated plaintext this call never actually stored. - return decrypted(existing); - }, - - async get(tenantId, triggerId) { - const [row] = await db - .select() - .from(webhookTrigger) - .where(and(eq(webhookTrigger.id, triggerId), eq(webhookTrigger.tenantId, tenantId))); - return row !== undefined ? decrypted(row) : undefined; - }, - - async getById(triggerId) { - const [row] = await db.select().from(webhookTrigger).where(eq(webhookTrigger.id, triggerId)); - return row !== undefined ? decrypted(row) : undefined; - }, - - async list(tenantId) { - // Never decrypted: `publicView` (management-routes.ts) never reads - // `secret` off a listed row, so paying for N decrypts here would be - // pure waste. - return db.select().from(webhookTrigger).where(eq(webhookTrigger.tenantId, tenantId)); - }, - - async rotateSecret(tenantId, triggerId, secret) { - const encryptedSecret = await credentialCipher.encrypt( - secret, - credentialAad(triggerId, "secret"), - ); - const [row] = await db - .update(webhookTrigger) - .set({ secret: encryptedSecret }) - .where(and(eq(webhookTrigger.id, triggerId), eq(webhookTrigger.tenantId, tenantId))) - .returning(); - // As in `create`, the plaintext is already known — return it - // directly instead of decrypting what was just encrypted. - return row !== undefined ? { ...row, secret } : undefined; - }, - - async setEnabled(tenantId, triggerId, enabled) { - const [row] = await db - .update(webhookTrigger) - .set({ enabled }) - .where(and(eq(webhookTrigger.id, triggerId), eq(webhookTrigger.tenantId, tenantId))) - .returning(); - return row; - }, - - async recordFired(triggerId, firedAt) { - await db - .update(webhookTrigger) - .set({ lastFiredAt: firedAt }) - .where(eq(webhookTrigger.id, triggerId)); - }, - - async remove(tenantId, triggerId) { - const deleted = await db - .delete(webhookTrigger) - .where(and(eq(webhookTrigger.id, triggerId), eq(webhookTrigger.tenantId, tenantId))) - .returning({ id: webhookTrigger.id }); - return deleted.length > 0; - }, - }; -} diff --git a/packages/webhook-triggers/test/ingress-routes.test.ts b/packages/webhook-triggers/test/ingress-routes.test.ts deleted file mode 100644 index bbce19f4e9..0000000000 --- a/packages/webhook-triggers/test/ingress-routes.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -// Exercises `createWebhookIngressRoutes`' HTTP surface: signature -// verification (valid/invalid/missing/stale), unknown/disabled-trigger -// handling, and payload parsing — with a fake `launch` seam so no -// database or folded-run launch machinery is involved. This is the -// trust-boundary route: no session, no tenant middleware. Unknown -// trigger, disabled trigger, bad signature, and a stale/replayed -// timestamp must all come back as the same generic 401 so a probe -// can't tell them apart. -import { describe, expect, test } from "bun:test"; -import { createWebhookIngressRoutes } from "../src/ingress-routes"; -import { signPayload, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER } from "../src/signature"; -import { createInMemoryWebhookTriggerStore } from "./test-support"; - -function nowSeconds(): string { - return String(Math.floor(Date.now() / 1000)); -} - -async function seedTrigger( - store: ReturnType, - overrides: { enabled?: boolean } = {}, -) { - const row = await store.create({ - id: "ins_trigger1", - tenantId: "tnt_1", - name: "Granola", - workflowDefinitionId: "def_1", - inputTemplate: "{{note.title}}", - secret: "s3cr3t", - createdBy: "prn_alice", - }); - if (overrides.enabled === false) { - await store.setEnabled("tnt_1", row.id, false); - } - return row; -} - -function buildApp( - launch: Parameters[0]["launch"] = async () => ({ - instanceId: "ins_x", - triggerAddress: "ins_x@acme.example", - }), -) { - const store = createInMemoryWebhookTriggerStore(); - const app = createWebhookIngressRoutes({ store, launch }); - return { app, store }; -} - -describe("POST /:triggerId", () => { - test("launches on a validly signed payload with a fresh timestamp", async () => { - let launchedWith: unknown; - const { app, store } = buildApp(async (_trigger, payload) => { - launchedWith = payload; - return { instanceId: "ins_new", triggerAddress: "ins_new@acme.example" }; - }); - await seedTrigger(store); - - const body = JSON.stringify({ note: { title: "Q3 planning" } }); - const timestamp = nowSeconds(); - const response = await app.request("/ins_trigger1", { - method: "POST", - headers: { - "content-type": "application/json", - [WEBHOOK_TIMESTAMP_HEADER]: timestamp, - [WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", timestamp, body), - }, - body, - }); - - expect(response.status).toBe(202); - expect(launchedWith).toEqual({ note: { title: "Q3 planning" } }); - const trigger = await store.getById("ins_trigger1"); - expect(trigger?.lastFiredAt).not.toBeNull(); - }); - - async function expectUnauthorizedEnvelope(response: Response): Promise { - const body = (await response.json()) as { - error: { code: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("unauthorized"); - expect(body.error.userMessage).toBe("invalid or missing signature"); - expect(typeof body.error.refId).toBe("string"); - expect(body.error.refId.length).toBeGreaterThan(0); - } - - test("rejects a missing signature header with the generic unauthorized response", async () => { - const { app, store } = buildApp(); - await seedTrigger(store); - - const response = await app.request("/ins_trigger1", { - method: "POST", - headers: { - "content-type": "application/json", - [WEBHOOK_TIMESTAMP_HEADER]: nowSeconds(), - }, - body: "{}", - }); - - expect(response.status).toBe(401); - await expectUnauthorizedEnvelope(response); - }); - - test("rejects a missing timestamp header with the generic unauthorized response", async () => { - const { app, store } = buildApp(); - await seedTrigger(store); - - const body = "{}"; - const response = await app.request("/ins_trigger1", { - method: "POST", - headers: { - "content-type": "application/json", - [WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", nowSeconds(), body), - }, - body, - }); - - expect(response.status).toBe(401); - await expectUnauthorizedEnvelope(response); - }); - - test("rejects a stale timestamp (a replayed delivery) with the generic unauthorized response", async () => { - const { app, store } = buildApp(); - await seedTrigger(store); - - const body = "{}"; - const staleTimestamp = String(Math.floor(Date.now() / 1000) - 301); - const response = await app.request("/ins_trigger1", { - method: "POST", - headers: { - "content-type": "application/json", - [WEBHOOK_TIMESTAMP_HEADER]: staleTimestamp, - [WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", staleTimestamp, body), - }, - body, - }); - - expect(response.status).toBe(401); - await expectUnauthorizedEnvelope(response); - }); - - test("rejects a signature computed with the wrong secret with the generic unauthorized response", async () => { - const { app, store } = buildApp(); - await seedTrigger(store); - - const body = "{}"; - const timestamp = nowSeconds(); - const response = await app.request("/ins_trigger1", { - method: "POST", - headers: { - "content-type": "application/json", - [WEBHOOK_TIMESTAMP_HEADER]: timestamp, - [WEBHOOK_SIGNATURE_HEADER]: signPayload("wrong-secret", timestamp, body), - }, - body, - }); - - expect(response.status).toBe(401); - await expectUnauthorizedEnvelope(response); - }); - - test("responds to an unknown trigger id with the same generic unauthorized response, not a 404", async () => { - const { app } = buildApp(); - const response = await app.request("/no-such-trigger", { - method: "POST", - body: "{}", - }); - expect(response.status).toBe(401); - await expectUnauthorizedEnvelope(response); - }); - - test("responds to a disabled trigger with the same generic unauthorized response even with a valid signature", async () => { - const { app, store } = buildApp(); - await seedTrigger(store, { enabled: false }); - - const body = "{}"; - const timestamp = nowSeconds(); - const response = await app.request("/ins_trigger1", { - method: "POST", - headers: { - [WEBHOOK_TIMESTAMP_HEADER]: timestamp, - [WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", timestamp, body), - }, - body, - }); - - expect(response.status).toBe(401); - await expectUnauthorizedEnvelope(response); - }); - - test("400s on a validly signed but non-JSON body", async () => { - const { app, store } = buildApp(); - await seedTrigger(store); - - const body = "not json"; - const timestamp = nowSeconds(); - const response = await app.request("/ins_trigger1", { - method: "POST", - headers: { - [WEBHOOK_TIMESTAMP_HEADER]: timestamp, - [WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", timestamp, body), - }, - body, - }); - - expect(response.status).toBe(400); - }); -}); diff --git a/packages/webhook-triggers/test/launch.test.ts b/packages/webhook-triggers/test/launch.test.ts deleted file mode 100644 index 6d7e54fa81..0000000000 --- a/packages/webhook-triggers/test/launch.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -// Proves `launchWebhookTrigger` provisions through Interchange and -// hardens the opening-mail send: a delivery already accepted has -// already committed a real run, so a failed `routeMail` must not -// throw past this function (or `createWebhookIngressRoutes` would -// reject an already-launched delivery, and a retried webhook client -// would then mint a duplicate run for the same event). -import { describe, expect, mock, test } from "bun:test"; -import { WORKFLOW_SOURCE_ENTRY } from "@corbits/workflows"; -import { DEFAULT_ASSET_REF } from "@intx/hub-sessions"; -import { base64Decode } from "@intx/types"; - -const actualDb = await import("@intx/db"); - -const DEFAULT_VISIBLE_OFFERINGS = [ - { - offering: { id: "off_1", priority: 0 }, - model: { canonicalName: "claude-sonnet-5" }, - provider: { name: "anthropic" }, - }, - { - offering: { id: "off_2", priority: 10 }, - model: { canonicalName: "ignored-lower" }, - provider: { name: "other" }, - }, -]; - -let visibleOfferings: typeof DEFAULT_VISIBLE_OFFERINGS = [...DEFAULT_VISIBLE_OFFERINGS]; - -mock.module("@intx/db", () => ({ - ...actualDb, - listVisibleOfferings: async () => visibleOfferings, -})); - -let reportErrorCalls: unknown[] = []; - -mock.module("@corbits/error-sink", () => ({ - reportError: (...args: unknown[]) => { - reportErrorCalls.push(args); - return "ref_test"; - }, -})); - -const { launchWebhookTrigger } = await import("../src/launch"); - -const DEFINITION_ROW = { - id: "wfd_1", - name: "webhook agent", - tenantId: "ten_1", - status: "deployed" as const, - assetId: "ast_1", - grantRequirements: [], -}; - -const TENANT_ROW = { - id: "ten_1", - domain: "acme.workbench.test", -}; - -function createFakeDb() { - return { - query: { - workflowDefinition: { findFirst: async () => DEFINITION_ROW }, - tenant: { findFirst: async () => TENANT_ROW }, - workflowRun: { - findFirst: async () => ({ - id: INTERCHANGE_RUN_ID, - tenantId: TENANT_ROW.id, - definitionId: DEFINITION_ROW.id, - address: INTERCHANGE_ADDRESS, - principalId: null, - }), - }, - }, - insert: () => ({ - values: () => ({ - onConflictDoNothing: async () => undefined, - }), - }), - }; -} - -let eventCollectorCreateCalls: unknown[] = []; - -function createFakeEventCollectors() { - return { - create: (...args: unknown[]) => { - eventCollectorCreateCalls.push(args); - }, - has: () => false, - }; -} - -const TRIGGER = { - id: "wht_1", - tenantId: "ten_1", - name: "Deploy hook", - workflowDefinitionId: "wfd_1", - inputTemplate: "deployed: {{status}}", - secret: "shh", - enabled: true, - createdBy: "usr_1", - createdAt: new Date(), - lastFiredAt: null, -}; - -const taggedCipher = { - encrypt: async (plaintext: string) => plaintext, - decrypt: async (blob: string) => blob, -}; - -const INTERCHANGE_RUN_ID = "wfr_interchange"; -const INTERCHANGE_ADDRESS = `${INTERCHANGE_RUN_ID}@${TENANT_ROW.domain}`; -const COMMIT_SHA = "sha_rendered"; - -type PrepareArgs = { - readonly tenantId: string; - readonly anchorRunId: string; - readonly sessionId: string; - readonly deploymentDomain: string; - readonly source: unknown; - readonly entry: string; - readonly definitionAssetId: string; - readonly sourceAuthorityPrincipalId: string; - readonly sourceOfferingIds: readonly string[]; - readonly defaultSourceOfferingId: string; - readonly deployContent: unknown; - readonly toolPackagePins?: readonly unknown[]; -}; - -type RouteMailCall = { - readonly address: string; - readonly base64: string; - readonly authenticatedSender: string; - readonly messageId: string; -}; - -let resolveRefCalls: unknown[] = []; -let prepareCalls: PrepareArgs[] = []; -let routeMailCalls: RouteMailCall[] = []; -let routeMailImpl: () => boolean = () => true; -let cryptoGetKeys: string[] = []; -let isRoutableForTest = true; - -function baseDeps() { - return { - db: createFakeDb() as never, - credentialCipher: taggedCipher, - cryptoProviderCache: { - get: async (key: string) => { - cryptoGetKeys.push(key); - return { sign: async () => new Uint8Array(64) } as never; - }, - }, - repoStore: { - resolveRef: async (...args: unknown[]) => { - resolveRefCalls.push(args); - return COMMIT_SHA; - }, - }, - workflowAllocationService: { - prepareProvisionedDeployment: async (args: PrepareArgs) => { - prepareCalls.push(args); - return { - anchorRunId: INTERCHANGE_RUN_ID, - deploymentAddress: INTERCHANGE_ADDRESS, - allocationId: "sal_1", - status: "pending" as const, - }; - }, - }, - sidecarRouter: { - routeMail: ( - address: string, - base64: string, - authenticatedSender: string, - messageId?: string, - ) => { - routeMailCalls.push({ address, base64, authenticatedSender, messageId: messageId ?? "" }); - return routeMailImpl(); - }, - }, - isRoutable: () => isRoutableForTest, - eventCollectors: createFakeEventCollectors(), - }; -} - -function resetLaunchSpies() { - resolveRefCalls = []; - prepareCalls = []; - routeMailCalls = []; - cryptoGetKeys = []; - reportErrorCalls = []; - eventCollectorCreateCalls = []; - visibleOfferings = [...DEFAULT_VISIBLE_OFFERINGS]; - routeMailImpl = () => true; - isRoutableForTest = true; -} - -function decodedContent(call: RouteMailCall): string { - return new TextDecoder().decode(base64Decode(call.base64)); -} - -describe("launchWebhookTrigger", () => { - test("still returns the Interchange run when input delivery fails after prepare", async () => { - resetLaunchSpies(); - routeMailImpl = () => false; - - const result = await launchWebhookTrigger(baseDeps(), TRIGGER, { - status: "ok", - }); - - expect(result).toEqual({ - instanceId: INTERCHANGE_RUN_ID, - triggerAddress: INTERCHANGE_ADDRESS, - }); - expect(prepareCalls).toHaveLength(1); - expect(resolveRefCalls).toHaveLength(1); - // isRoutable defaults true in this fixture, so the unreachable send - // is retried once per `deliverWhenRoutable`'s contract before the - // failure is reported. - expect(routeMailCalls).toHaveLength(2); - }); - - // A freshly provisioned run's sidecar takes several seconds to - // boot and register with the hub. The opening mail can land in that - // gap and fail "agent is unreachable" even though the run deployed - // fine — `deliverWhenRoutable` must retry once the address becomes - // routable rather than dropping the mail for good. - test("retries the opening mail once the freshly provisioned run becomes routable", async () => { - resetLaunchSpies(); - isRoutableForTest = false; - let sendAttempts = 0; - routeMailImpl = () => { - sendAttempts += 1; - return sendAttempts !== 1; - }; - - const deps = baseDeps(); - const sendPromise = launchWebhookTrigger(deps, TRIGGER, { status: "ok" }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - isRoutableForTest = true; - - const result = await sendPromise; - - expect(result).toEqual({ - instanceId: INTERCHANGE_RUN_ID, - triggerAddress: INTERCHANGE_ADDRESS, - }); - expect(routeMailCalls).toHaveLength(2); - expect(reportErrorCalls).toHaveLength(0); - }); - - test("reports the delivery failure with the run's context", async () => { - resetLaunchSpies(); - routeMailImpl = () => false; - - const result = await launchWebhookTrigger(baseDeps(), TRIGGER, { - status: "ok", - }); - - expect(reportErrorCalls).toHaveLength(1); - const [cause, context] = reportErrorCalls[0] as [ - unknown, - { - operation: string; - tenantId: string; - agentId: string; - extra: Record; - }, - ]; - expect(cause).toBeInstanceOf(Error); - expect(context.operation).toBe("webhookTriggers.launch.deliverInput"); - expect(context.tenantId).toBe(TRIGGER.tenantId); - expect(context.agentId).toBe(result.triggerAddress); - expect(context.extra).toEqual({ - instanceId: result.instanceId, - triggerId: TRIGGER.id, - }); - }); - - test("does not report anything when delivery succeeds", async () => { - resetLaunchSpies(); - - await launchWebhookTrigger(baseDeps(), TRIGGER, { status: "ok" }); - - expect(reportErrorCalls).toHaveLength(0); - }); - - test("returns the Interchange run normally when delivery succeeds", async () => { - resetLaunchSpies(); - - const result = await launchWebhookTrigger(baseDeps(), TRIGGER, { - status: "ok", - }); - - expect(result).toEqual({ - instanceId: INTERCHANGE_RUN_ID, - triggerAddress: INTERCHANGE_ADDRESS, - }); - expect(routeMailCalls).toHaveLength(1); - const call = routeMailCalls[0]; - if (call === undefined) { - throw new Error("expected routeMail to be called"); - } - const preparedArgs = prepareCalls[0]; - if (preparedArgs === undefined) { - throw new Error("expected prepareProvisionedDeployment to be called"); - } - expect(decodedContent(call)).toContain("deployed: ok"); - expect(call.authenticatedSender).toBe(`webhook-trigger:${TRIGGER.id}`); - expect(call.address).toBe(INTERCHANGE_ADDRESS); - }); - - test("resolves the definition asset HEAD and prepares through Interchange", async () => { - resetLaunchSpies(); - - await launchWebhookTrigger(baseDeps(), TRIGGER, { status: "ok" }); - - expect(resolveRefCalls).toEqual([ - [{ kind: "hub" }, { kind: "workflow", id: DEFINITION_ROW.assetId }, DEFAULT_ASSET_REF], - ]); - - expect(prepareCalls).toHaveLength(1); - expect(prepareCalls[0]).toMatchObject({ - tenantId: TRIGGER.tenantId, - deploymentDomain: TENANT_ROW.domain, - entry: WORKFLOW_SOURCE_ENTRY, - definitionAssetId: DEFINITION_ROW.assetId, - sourceAuthorityPrincipalId: TRIGGER.createdBy, - sourceOfferingIds: ["off_1", "off_2"], - defaultSourceOfferingId: "off_1", - deployContent: { systemPrompt: "" }, - source: { - kind: "asset", - assetId: DEFINITION_ROW.assetId, - package: { format: "source", commitSha: COMMIT_SHA }, - }, - }); - expect(prepareCalls[0]?.toolPackagePins).toBeUndefined(); - }); - - // Part 1: a webhook delivery is a one-off native run — the - // native `workflow_run` row Interchange commits answers identity, so - // launch provisions it and delivers the opening mail with no - // `workbench_launch` mirror write (the deps surface no longer carries - // `persistLaunch`/`recordLaunchSources` at all). - test("provisions the native run with no workbench_launch mirror write", async () => { - resetLaunchSpies(); - - const result = await launchWebhookTrigger(baseDeps(), TRIGGER, { - status: "ok", - }); - - expect(result.instanceId).toBe(INTERCHANGE_RUN_ID); - expect(prepareCalls).toHaveLength(1); - expect(routeMailCalls).toHaveLength(1); - expect(cryptoGetKeys).toEqual([INTERCHANGE_RUN_ID]); - }); -}); diff --git a/packages/webhook-triggers/test/management-routes.test.ts b/packages/webhook-triggers/test/management-routes.test.ts deleted file mode 100644 index 7379588650..0000000000 --- a/packages/webhook-triggers/test/management-routes.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Exercises `createWebhookTriggerRoutes`' HTTP surface: request -// parsing, the secret-exposure contract (present on create/rotate -// only), and grant-check wiring. Mounted into a bare `Hono` with an -// in-memory store fake — no database involved, our wiring only. -import { describe, expect, test } from "bun:test"; -import type { RequireGrant } from "@intx/hub-api"; -import { createWebhookTriggerRoutes } from "../src/management-routes"; -import { createInMemoryWebhookTriggerStore, mountAs } from "./test-support"; - -const allowAll: RequireGrant = () => async (_c, next) => { - await next(); -}; - -function buildApp(overrides: { requireGrant?: RequireGrant } = {}) { - const store = createInMemoryWebhookTriggerStore(); - const requireGrant = overrides.requireGrant ?? allowAll; - const app = mountAs(createWebhookTriggerRoutes({ store, requireGrant }), "prn_alice"); - return { app, store }; -} - -describe("POST /", () => { - test("creates a trigger and returns its secret exactly once", async () => { - const { app } = buildApp(); - const response = await app.request("/", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Granola note-taker", - workflowDefinitionId: "def_1", - inputTemplate: "New note: {{note.title}}", - }), - }); - - expect(response.status).toBe(201); - const body = (await response.json()) as { - id: string; - secret: string; - name: string; - }; - expect(typeof body.secret).toBe("string"); - expect(body.secret.length).toBeGreaterThan(0); - expect(body.name).toBe("Granola note-taker"); - }); - - test("rejects a malformed body with the structured error envelope", async () => { - const { app } = buildApp(); - const response = await app.request("/", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "missing fields" }), - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("bad_request"); - }); - - test("a second create for the same name/definition 409s rather than duplicating", async () => { - const { app } = buildApp(); - const post = () => - app.request("/", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "Granola note-taker", - workflowDefinitionId: "def_1", - inputTemplate: "New note: {{note.title}}", - }), - }); - - const first = await post(); - expect(first.status).toBe(201); - - const second = await post(); - expect(second.status).toBe(409); - const body = (await second.json()) as { error: { code: string } }; - expect(body.error.code).toBe("conflict"); - }); -}); - -describe("GET / and GET /:id", () => { - test("list and get never include the secret", async () => { - const { app } = buildApp(); - const createResponse = await app.request("/", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "trigger", - workflowDefinitionId: "def_1", - inputTemplate: "{{a}}", - }), - }); - const created = (await createResponse.json()) as { id: string }; - - const listResponse = await app.request("/"); - const list = (await listResponse.json()) as { - items: Record[]; - }; - expect(list.items).toHaveLength(1); - expect(list.items[0]).not.toHaveProperty("secret"); - - const getResponse = await app.request(`/${created.id}`); - const got = (await getResponse.json()) as Record; - expect(got).not.toHaveProperty("secret"); - }); - - test("get on an unknown id 404s", async () => { - const { app } = buildApp(); - const response = await app.request("/missing"); - expect(response.status).toBe(404); - }); -}); - -describe("POST /:id/rotate-secret", () => { - test("returns a new secret different from the original", async () => { - const { app } = buildApp(); - const createResponse = await app.request("/", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "trigger", - workflowDefinitionId: "def_1", - inputTemplate: "{{a}}", - }), - }); - const created = (await createResponse.json()) as { - id: string; - secret: string; - }; - - const rotateResponse = await app.request(`/${created.id}/rotate-secret`, { - method: "POST", - }); - expect(rotateResponse.status).toBe(200); - const rotated = (await rotateResponse.json()) as { secret: string }; - expect(rotated.secret).not.toBe(created.secret); - }); -}); - -describe("POST /:id/enabled and DELETE /:id", () => { - test("disable then delete round-trip", async () => { - const { app } = buildApp(); - const createResponse = await app.request("/", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "trigger", - workflowDefinitionId: "def_1", - inputTemplate: "{{a}}", - }), - }); - const created = (await createResponse.json()) as { id: string }; - - const disableResponse = await app.request(`/${created.id}/enabled`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ enabled: false }), - }); - expect(disableResponse.status).toBe(200); - const disabled = (await disableResponse.json()) as { enabled: boolean }; - expect(disabled.enabled).toBe(false); - - const deleteResponse = await app.request(`/${created.id}`, { - method: "DELETE", - }); - expect(deleteResponse.status).toBe(204); - - const getResponse = await app.request(`/${created.id}`); - expect(getResponse.status).toBe(404); - }); - - test("a denied grant is rejected before any store mutation", async () => { - const { app, store } = buildApp({ - requireGrant: () => async (c) => c.json({ error: { code: "forbidden", message: "no" } }, 403), - }); - - const response = await app.request("/", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "trigger", - workflowDefinitionId: "def_1", - inputTemplate: "{{a}}", - }), - }); - - expect(response.status).toBe(403); - expect(await store.list("tnt_1")).toHaveLength(0); - }); -}); diff --git a/packages/webhook-triggers/test/mapping.test.ts b/packages/webhook-triggers/test/mapping.test.ts deleted file mode 100644 index 48e93ad69a..0000000000 --- a/packages/webhook-triggers/test/mapping.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { renderInputTemplate } from "../src/mapping"; - -describe("renderInputTemplate", () => { - test("substitutes a top-level field", () => { - expect(renderInputTemplate("hello {{name}}", { name: "granola" })).toBe("hello granola"); - }); - - test("substitutes a nested dotted path", () => { - const payload = { meeting: { title: "Standup", attendees: 3 } }; - expect(renderInputTemplate("New meeting: {{meeting.title}}", payload)).toBe( - "New meeting: Standup", - ); - }); - - test("stringifies non-string leaf values", () => { - expect( - renderInputTemplate("count={{meeting.attendees}}", { - meeting: { attendees: 3 }, - }), - ).toBe("count=3"); - }); - - test("resolves a missing path to an empty string rather than throwing", () => { - expect(renderInputTemplate("value={{missing.path}}", {})).toBe("value="); - }); - - test("resolves a path through a non-object value to empty string", () => { - expect(renderInputTemplate("v={{a.b}}", { a: "not-an-object" })).toBe("v="); - }); - - test("renders an object leaf as JSON", () => { - expect(renderInputTemplate("data={{payload}}", { payload: { a: 1 } })).toBe('data={"a":1}'); - }); - - test("leaves a template with no placeholders untouched", () => { - expect(renderInputTemplate("no placeholders here", { a: 1 })).toBe("no placeholders here"); - }); -}); diff --git a/packages/webhook-triggers/test/migrations.test.ts b/packages/webhook-triggers/test/migrations.test.ts deleted file mode 100644 index 33dde881ea..0000000000 --- a/packages/webhook-triggers/test/migrations.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh -// checkout still runs the unit gates), and turned into a loud failure -// by CI=true so the suite can never silently vanish from CI. -// Runs against its own scratch database, never the developer's or the -// walking-skeleton suite's, so a failure here can never corrupt either. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../test/database-url"; -import { applyWebhookTriggersMigrations } from "../src/migrations"; -import { dbGate } from "../../../test/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_webhook_triggers_migrations_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("applyWebhookTriggersMigrations", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - test("applies the trigger table into its own schema and is idempotent on a second run, even concurrently", async () => { - await applyWebhookTriggersMigrations(scratchUrl); - await Promise.all([ - applyWebhookTriggersMigrations(scratchUrl), - applyWebhookTriggersMigrations(scratchUrl), - ]); - - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const tables = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'webhook_triggers' AND table_name = 'webhook_trigger'`, - ); - expect(tables.map((row) => String(row["table_name"]))).toEqual(["webhook_trigger"]); - - const inPublic = await sql.unsafe( - `SELECT 1 FROM information_schema.tables ` + - `WHERE table_schema = 'public' AND table_name = 'webhook_trigger'`, - ); - expect(inPublic).toHaveLength(0); - } finally { - await sql.end(); - } - }, 20000); -}); diff --git a/packages/webhook-triggers/test/signature.test.ts b/packages/webhook-triggers/test/signature.test.ts deleted file mode 100644 index 97f6ff0cd5..0000000000 --- a/packages/webhook-triggers/test/signature.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { generateWebhookSecret, signPayload, verifySignature } from "../src/signature"; - -function nowSeconds(): string { - return String(Math.floor(Date.now() / 1000)); -} - -describe("generateWebhookSecret", () => { - test("mints distinct high-entropy secrets", () => { - const a = generateWebhookSecret(); - const b = generateWebhookSecret(); - expect(a).not.toEqual(b); - expect(a.length).toBeGreaterThanOrEqual(32); - }); -}); - -describe("verifySignature", () => { - const secret = generateWebhookSecret(); - const body = JSON.stringify({ hello: "world" }); - - test("accepts a correctly signed payload with a fresh timestamp", () => { - const timestamp = nowSeconds(); - const signature = signPayload(secret, timestamp, body); - expect(verifySignature(secret, timestamp, body, signature)).toBe(true); - }); - - test("rejects a payload signed with a different secret", () => { - const timestamp = nowSeconds(); - const wrongSignature = signPayload(generateWebhookSecret(), timestamp, body); - expect(verifySignature(secret, timestamp, body, wrongSignature)).toBe(false); - }); - - test("rejects a tampered body against the original signature", () => { - const timestamp = nowSeconds(); - const signature = signPayload(secret, timestamp, body); - const tamperedBody = JSON.stringify({ hello: "mallory" }); - expect(verifySignature(secret, timestamp, tamperedBody, signature)).toBe(false); - }); - - test("rejects a missing signature header", () => { - const timestamp = nowSeconds(); - expect(verifySignature(secret, timestamp, body, undefined)).toBe(false); - expect(verifySignature(secret, timestamp, body, "")).toBe(false); - }); - - test("rejects a non-hex signature without throwing", () => { - const timestamp = nowSeconds(); - expect(verifySignature(secret, timestamp, body, "not-hex-at-all!!")).toBe(false); - }); - - test("rejects a well-formed-but-wrong-length hex signature", () => { - const timestamp = nowSeconds(); - expect(verifySignature(secret, timestamp, body, "abcd")).toBe(false); - }); - - test("rejects a signature computed over a different timestamp than the one presented", () => { - const timestamp = nowSeconds(); - const signature = signPayload(secret, timestamp, body); - const laterTimestamp = String(Number(timestamp) + 1); - expect(verifySignature(secret, laterTimestamp, body, signature)).toBe(false); - }); - - test("rejects a replayed delivery once its timestamp is outside the tolerance window", () => { - const staleTimestamp = String(Math.floor(Date.now() / 1000) - 301); - const signature = signPayload(secret, staleTimestamp, body); - expect(verifySignature(secret, staleTimestamp, body, signature)).toBe(false); - }); - - test("accepts a timestamp comfortably inside the tolerance window", () => { - // 299s, not the exact 300s boundary: the boundary itself is racy - // against wall-clock time elapsing between signing and verifying. - const edgeTimestamp = String(Math.floor(Date.now() / 1000) - 299); - const signature = signPayload(secret, edgeTimestamp, body); - expect(verifySignature(secret, edgeTimestamp, body, signature)).toBe(true); - }); - - test("rejects a timestamp forged too far into the future", () => { - const futureTimestamp = String(Math.floor(Date.now() / 1000) + 301); - const signature = signPayload(secret, futureTimestamp, body); - expect(verifySignature(secret, futureTimestamp, body, signature)).toBe(false); - }); - - test("rejects a missing timestamp header", () => { - const signature = signPayload(secret, nowSeconds(), body); - expect(verifySignature(secret, undefined, body, signature)).toBe(false); - expect(verifySignature(secret, "", body, signature)).toBe(false); - }); - - test("rejects a non-numeric timestamp header without treating it as fresh", () => { - const signature = signPayload(secret, "not-a-number", body); - expect(verifySignature(secret, "not-a-number", body, signature)).toBe(false); - }); -}); diff --git a/packages/webhook-triggers/test/store.drizzle.test.ts b/packages/webhook-triggers/test/store.drizzle.test.ts deleted file mode 100644 index c51c7908fe..0000000000 --- a/packages/webhook-triggers/test/store.drizzle.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring this package's `migrations.test.ts`. Runs -// against its own scratch database, never the developer's or the -// walking-skeleton suite's. -// -// Proves the one thing an in-memory `WebhookTriggerStore` fake cannot: -// that `createDrizzleWebhookTriggerStore` actually round-trips a secret -// through `CredentialCipher` — encrypted on disk, decrypted back to the -// exact plaintext on read — and that a real (non-noop) cipher's -// ciphertext is not the plaintext secret, so a raw table dump does not -// disclose it. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; -import { createEnvKeyCredentialCipher, createNoopCredentialCipher } from "@intx/crypto"; - -import { e2eDatabaseUrl } from "../../../test/database-url"; -import { applyWebhookTriggersMigrations } from "../src/migrations"; -import { createDrizzleWebhookTriggerStore } from "../src/store"; -import { dbGate } from "../../../test/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_webhook_triggers_store_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const TENANT_ID = "tnt_1"; -const KEY = new Uint8Array(32).fill(7); - -describeIfDb("createDrizzleWebhookTriggerStore: secret encryption at rest", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await applyWebhookTriggersMigrations(scratchUrl); - }); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("a real cipher's stored secret is not the plaintext, and round-trips correctly", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const db = drizzle(sql); - const cipher = createEnvKeyCredentialCipher(KEY); - const store = createDrizzleWebhookTriggerStore(db, cipher); - - const created = await store.create({ - id: "wht_encrypted", - tenantId: TENANT_ID, - name: "Support digest", - workflowDefinitionId: "def_1", - inputTemplate: "New delivery.", - secret: "the-plaintext-secret", - createdBy: "user_1", - }); - expect(created.secret).toBe("the-plaintext-secret"); - - const [rawRow] = - await sql`select secret from webhook_triggers.webhook_trigger where id = ${created.id}`; - expect(rawRow?.["secret"]).not.toBe("the-plaintext-secret"); - expect(String(rawRow?.["secret"])).toContain("enc:"); - - const fetched = await store.get(TENANT_ID, created.id); - expect(fetched?.secret).toBe("the-plaintext-secret"); - - const fetchedById = await store.getById(created.id); - expect(fetchedById?.secret).toBe("the-plaintext-secret"); - } finally { - await sql.end(); - } - }); - - test("rotateSecret re-encrypts and the new secret round-trips", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const db = drizzle(sql); - const cipher = createEnvKeyCredentialCipher(KEY); - const store = createDrizzleWebhookTriggerStore(db, cipher); - - const created = await store.create({ - id: "wht_rotate", - tenantId: TENANT_ID, - name: "Rotation test", - workflowDefinitionId: "def_1", - inputTemplate: "New delivery.", - secret: "original-secret", - createdBy: "user_1", - }); - - const rotated = await store.rotateSecret(TENANT_ID, created.id, "rotated-secret"); - expect(rotated?.secret).toBe("rotated-secret"); - - const [rawRow] = - await sql`select secret from webhook_triggers.webhook_trigger where id = ${created.id}`; - expect(rawRow?.["secret"]).not.toBe("rotated-secret"); - - const fetched = await store.get(TENANT_ID, created.id); - expect(fetched?.secret).toBe("rotated-secret"); - } finally { - await sql.end(); - } - }); - - test("a noop cipher (dev/test default) round-trips as plaintext identity", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const db = drizzle(sql); - const store = createDrizzleWebhookTriggerStore(db, createNoopCredentialCipher()); - - const created = await store.create({ - id: "wht_noop", - tenantId: TENANT_ID, - name: "Noop cipher test", - workflowDefinitionId: "def_1", - inputTemplate: "New delivery.", - secret: "plain-secret", - createdBy: "user_1", - }); - - const [rawRow] = - await sql`select secret from webhook_triggers.webhook_trigger where id = ${created.id}`; - expect(rawRow?.["secret"]).toBe("plain-secret"); - - const fetched = await store.get(TENANT_ID, created.id); - expect(fetched?.secret).toBe("plain-secret"); - } finally { - await sql.end(); - } - }); - - // `startReviewingRepos`'s check-then-act - // (hasWebhookTrigger then createWebhookTrigger) can race two - // concurrent "start reviewing" calls for the same repo past the - // read before either write lands. `ensure` is the actual backstop - // apps/hub's `createWebhookTrigger` port binds to instead of - // `create` — reconstructs the audit's finding (2 live triggers for - // one repo) against the real `webhook_trigger_tenant_definition_name_unique` - // index (migration 0003). - test("two concurrent ensure() calls for the same tenant/definition/name settle on exactly one trigger", async () => { - const sql = postgres(scratchUrl, { max: 5 }); - try { - const db = drizzle(sql); - const cipher = createEnvKeyCredentialCipher(KEY); - const store = createDrizzleWebhookTriggerStore(db, cipher); - - const input = (id: string) => ({ - id, - tenantId: TENANT_ID, - name: "acme/widgets pull-request-opened", - workflowDefinitionId: "def_code_review", - inputTemplate: "Review the pull request at {{pull_request.html_url}}", - secret: `secret-${id}`, - createdBy: "user_1", - }); - - const [first, second] = await Promise.all([ - store.ensure(input("wht_race_1")), - store.ensure(input("wht_race_2")), - ]); - - expect(first.id).toBe(second.id); - expect(first.secret).toBe(second.secret); - - const rows = await sql` - select id from webhook_triggers.webhook_trigger - where tenant_id = ${TENANT_ID} and workflow_definition_id = 'def_code_review' - and name = 'acme/widgets pull-request-opened' - `; - expect(rows).toHaveLength(1); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/webhook-triggers/test/test-support.ts b/packages/webhook-triggers/test/test-support.ts deleted file mode 100644 index 64e74bba42..0000000000 --- a/packages/webhook-triggers/test/test-support.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Shared test harness for `createWebhookTriggerRoutes`' HTTP surface: -// an in-memory `WebhookTriggerStore` fake and a tenant/principal- -// injecting mount, mirroring `@corbits/chat`'s `test/test-support.ts`. -// Not a production module — lives in `test/` only. -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; - -import type { TenantEnv } from "@intx/hub-api"; -import type { WebhookTriggerRow } from "../src/schema"; -import type { CreateWebhookTriggerInput, WebhookTriggerStore } from "../src/store"; - -export const TENANT = { - id: "tnt_1", - name: "Acme", - slug: "acme", - domain: "acme.example", - parentId: null, - config: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - -export function principal(id: string) { - return { - id, - tenantId: TENANT.id, - kind: "user" as const, - refId: id, - status: "active" as const, - createdAt: new Date(), - updatedAt: new Date(), - }; -} - -export function createInMemoryWebhookTriggerStore(): WebhookTriggerStore { - const rows = new Map(); - - function findByName( - tenantId: string, - workflowDefinitionId: string, - name: string, - ): WebhookTriggerRow | undefined { - return [...rows.values()].find( - (row) => - row.tenantId === tenantId && - row.workflowDefinitionId === workflowDefinitionId && - row.name === name, - ); - } - - function newRow(input: CreateWebhookTriggerInput): WebhookTriggerRow { - return { - id: input.id, - tenantId: input.tenantId, - name: input.name, - workflowDefinitionId: input.workflowDefinitionId, - inputTemplate: input.inputTemplate, - secret: input.secret, - enabled: true, - createdBy: input.createdBy, - createdAt: new Date(), - lastFiredAt: null, - }; - } - - return { - async create(input: CreateWebhookTriggerInput) { - if (findByName(input.tenantId, input.workflowDefinitionId, input.name)) { - // Mirrors the real driver's shape for a `23505` unique - // violation, so callers exercising `isUniqueViolation`-style - // handling against this fake see the same thing production does. - throw Object.assign( - new Error(`webhook trigger ${input.name} already exists for this workflow definition`), - { code: "23505" }, - ); - } - const row = newRow(input); - rows.set(row.id, row); - return row; - }, - async ensure(input: CreateWebhookTriggerInput) { - const existing = findByName(input.tenantId, input.workflowDefinitionId, input.name); - if (existing) return existing; - const row = newRow(input); - rows.set(row.id, row); - return row; - }, - async get(tenantId, triggerId) { - const row = rows.get(triggerId); - return row?.tenantId === tenantId ? row : undefined; - }, - async getById(triggerId) { - return rows.get(triggerId); - }, - async list(tenantId) { - return [...rows.values()].filter((row) => row.tenantId === tenantId); - }, - async rotateSecret(tenantId, triggerId, secret) { - const row = rows.get(triggerId); - if (row === undefined || row.tenantId !== tenantId) return undefined; - const updated = { ...row, secret }; - rows.set(triggerId, updated); - return updated; - }, - async setEnabled(tenantId, triggerId, enabled) { - const row = rows.get(triggerId); - if (row === undefined || row.tenantId !== tenantId) return undefined; - const updated = { ...row, enabled }; - rows.set(triggerId, updated); - return updated; - }, - async recordFired(triggerId, firedAt) { - const row = rows.get(triggerId); - if (row === undefined) return; - rows.set(triggerId, { ...row, lastFiredAt: firedAt }); - }, - async remove(tenantId, triggerId) { - const row = rows.get(triggerId); - if (row === undefined || row.tenantId !== tenantId) return false; - rows.delete(triggerId); - return true; - }, - }; -} - -export function mountAs(routes: Hono, principalId: string): Hono { - const asPrincipal: MiddlewareHandler = async (c, next) => { - c.set("tenant", TENANT); - c.set("principal", principal(principalId)); - await next(); - }; - const app = new Hono(); - app.use("*", asPrincipal); - app.route("/", routes); - return app; -} diff --git a/packages/webhook-triggers/tsconfig.json b/packages/webhook-triggers/tsconfig.json deleted file mode 100644 index d7611c1229..0000000000 --- a/packages/webhook-triggers/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "test"], - "compilerOptions": { - "types": ["bun"], - "noEmit": true - } -} From 99bd2a2097375ea1ee3a962e37b59960e4830832 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 18:48:46 -0700 Subject: [PATCH 2/2] chore(build): drop the collections project reference (CL-8389) --- tsconfig.build.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/tsconfig.build.json b/tsconfig.build.json index fc244fecb4..166c04807c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -10,9 +10,6 @@ { "path": "./packages/approvals/tsconfig.src.json" }, - { - "path": "./packages/collections/tsconfig.src.json" - }, { "path": "./packages/cron/tsconfig.src.json" },