diff --git a/AGENTS.md b/AGENTS.md index a613de4..c26d08e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ storage of its own, or scheduling (`@corbits/cron`). its own run principal, `deriveRunPrincipalId(tenantId, runId)`, through Interchange's mail-triggered grant path, never as the credential owner. - Trigger mail is signed by the durable per-tenant system sender - (`webhook@domain`), whose key is co-delivered on the run-grants barrier. + (`webhook@domain`), whose key is co-delivered with the run grants in the single `routeMail` call. - No unsigned mode: a credential without a verifier matches no hook. - Unknown hooks and name collisions both answer `404`. - `exactOptionalPropertyTypes` is on: omit optional keys, never assign diff --git a/README.md b/README.md index 989a811..4ecad21 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,9 @@ Signatures are checked over the raw request bytes, and the body is forwarded unc | `createRunTriggerDeliverer(opts)` | Delivers trigger mail to a live run as its run principal. | | `createTenantSystemSender({ db, principalKeyStore })` | Durable per-tenant sender (`@domain`) that signs trigger mail. | | `isRunTriggerUnroutable(error)` | Narrows to `{ code, address, runId }` when the router has no route. | -| `RUN_GRANTS_NOT_ROUTABLE`, `RUN_MAIL_NOT_ROUTABLE` | The two `code` values. | +| `RUN_MAIL_NOT_ROUTABLE` | The error `code`. | | `RunTriggerUnroutableError` | The error `isRunTriggerUnroutable` matches. | -| `HookMailRouter` | Router type the host passes in: `routeMail` and `sendRunGrants`. | +| `HookRouter` | `Pick`, the router the host passes in. | | `MailDeliverer` | `{ to(address, content, tenantId, subject) }`, what the deliverer returns. | ### `runWebhookMigrations(dbConfig, { schema })` @@ -78,7 +78,7 @@ Run `runWebhookMigrations` at hub start, after `runMigrations`, then build the r import { createEnvKeyCredentialCipher } from "@intx/crypto"; import { createDB, createPrincipalKeyStore, runMigrations } from "@intx/db"; import { hexDecode } from "@intx/types"; -import { createHookRoutes, type HookMailRouter } from "@corbits/webhooks"; +import { createHookRoutes, type HookRouter } from "@corbits/webhooks"; import { runWebhookMigrations } from "@corbits/webhooks/migrations"; const dbConfig = { @@ -93,7 +93,7 @@ await runWebhookMigrations(dbConfig, { schema: "public" }); const { db } = createDB(dbConfig); -export const hookRoutes = (router: HookMailRouter) => +export const hookRoutes = (router: HookRouter) => createHookRoutes({ db, credentialCipher: createEnvKeyCredentialCipher( @@ -143,6 +143,13 @@ curl -X POST "$HUB/api/hooks/crd_…" \ The hub answers `202 { "ok": true, "to": "run_…@acme.example" }` and the run starts. +## Upgrading from 0.2 + +- `HookMailRouter` is replaced by `HookRouter`, `Pick` from `@intx/hub-sessions`. Pass the hub's sidecar router as before; `sendRunGrants` is no longer used. +- Run grants and trigger mail go out in one `routeMail` call, so `RUN_GRANTS_NOT_ROUTABLE` and `RunTriggerUnroutableCode` are removed. Every unroutable delivery throws `RunTriggerUnroutableError` with `code` `RUN_MAIL_NOT_ROUTABLE`, and `new RunTriggerUnroutableError(address, runId)` no longer takes a code. +- `isRunTriggerUnroutable` uses `instanceof`; every consumer must resolve one copy of `@corbits/webhooks`. +- `@intx/hub-sessions` is a new peer dependency. + ## Upgrading from 0.1 - `installWebhooks(opts)` is removed. Call `app.route("/api/hooks", createHookRoutes(deps))` with the same `db`, `credentialCipher`, `principalKeyStore` and `router`; drop `app` from the options. diff --git a/bun.lock b/bun.lock index dbb9947..c55738d 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@intx/db": "0.4.0", "@intx/hub-api": "0.4.0", "@intx/hub-common": "0.4.0", + "@intx/hub-sessions": "0.4.0", "@intx/mime": "0.4.0", "@intx/types": "0.4.0", "@types/bun": "1.3.9", @@ -24,10 +25,12 @@ "@intx/db": "^0.4.0", "@intx/hub-api": "^0.4.0", "@intx/hub-common": "^0.4.0", + "@intx/hub-sessions": "^0.4.0", "@intx/mime": "^0.4.0", "@intx/types": "^0.4.0", "drizzle-orm": "^0.45.1", "hono": "^4.11.9", + "postgres": "^3.4.8", }, }, }, diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 0f42265..221bd47 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -10,7 +10,7 @@ import { import { createEnvKeyCredentialCipher } from "@intx/crypto"; import { principal, tenant } from "@intx/db/schema"; -import { createHookRoutes, type HookMailRouter } from "../src/index.js"; +import { createHookRoutes, type HookRouter } from "../src/index.js"; import { runWebhookMigrations } from "../src/migrations.js"; const PG_ENV = [ @@ -95,21 +95,16 @@ export const testCredentialCipher = createEnvKeyCredentialCipher( new Uint8Array(32).fill(7), ); -/** A sidecar router that records every frame and routes all of them. */ +/** A sidecar router that records every call and routes all of them. */ export function recordingRouter() { - const grants: Parameters[] = []; - const mail: Parameters[] = []; - const router: HookMailRouter = { - sendRunGrants: (...args) => { - grants.push(args); - return true; - }, + const mail: Parameters[] = []; + const router: HookRouter = { routeMail: (...args) => { mail.push(args); return true; }, }; - return { router, grants, mail }; + return { router, mail }; } /** @@ -118,7 +113,7 @@ export function recordingRouter() { */ export function mountHookRoutes(opts: { db: TestDb["db"]; - router: HookMailRouter; + router: HookRouter; }): Hono { const app = new Hono(); app.route( diff --git a/e2e/hooks.test.ts b/e2e/hooks.test.ts index 7f6a522..1f0ff6a 100644 --- a/e2e/hooks.test.ts +++ b/e2e/hooks.test.ts @@ -172,7 +172,7 @@ describe.skipIf(!harnessDbAvailable())( }); expect(res.status).toBe(401); - expect(recorded.grants).toEqual([]); + expect(recorded.mail).toEqual([]); expect(recorded.mail).toEqual([]); const { runPrincipals, grants } = await runGrants(db()); expect(runPrincipals).toEqual([]); @@ -198,10 +198,10 @@ describe.skipIf(!harnessDbAvailable())( expect(grants.map((g) => [g.principalId, g.resource, g.action])).toEqual([ [runPrincipal?.id ?? "", "tool:read_file", "invoke"], ]); - const [frame] = recorded.grants; - expect(frame?.[0]).toBe(ADDRESS); - expect(frame?.[2]).toHaveLength(1); expect(recorded.mail).toHaveLength(1); + const [call] = recorded.mail; + expect(call?.[0]).toBe(ADDRESS); + expect(call?.[4]?.stepGrants).toHaveLength(1); }); test("a replayed delivery is 409 on every replica", async () => { diff --git a/package.json b/package.json index 03c7770..5c79fb5 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@intx/db": "0.4.0", "@intx/hub-api": "0.4.0", "@intx/hub-common": "0.4.0", + "@intx/hub-sessions": "0.4.0", "@intx/mime": "0.4.0", "@intx/types": "0.4.0", "@types/bun": "1.3.9", @@ -72,6 +73,7 @@ "@intx/db": "^0.4.0", "@intx/hub-api": "^0.4.0", "@intx/hub-common": "^0.4.0", + "@intx/hub-sessions": "^0.4.0", "@intx/mime": "^0.4.0", "@intx/types": "^0.4.0", "drizzle-orm": "^0.45.1", diff --git a/src/deliver.test.ts b/src/deliver.test.ts index abaf70c..b8a34fa 100644 --- a/src/deliver.test.ts +++ b/src/deliver.test.ts @@ -5,9 +5,10 @@ import { hexEncode } from "@intx/types"; import { createRunTriggerDeliverer, isRunTriggerUnroutable, + RUN_MAIL_NOT_ROUTABLE, + RunTriggerUnroutableError, } from "./deliver.js"; -import { RUN_GRANTS_NOT_ROUTABLE, RUN_MAIL_NOT_ROUTABLE } from "./deliver.js"; -import type { HookMailRouter } from "./deliver.js"; +import type { HookRouter } from "./deliver.js"; import type { SystemSender } from "./system-sender.js"; const ADDRESS = "run_0123456789abcdef@localhost"; @@ -39,40 +40,17 @@ async function durableSystemSender() { } function recordingRouter() { - const grants: { - address: string; - senderIdentities: Parameters[3]; - }[] = []; - const mail: { authenticatedSender: string; rawMessage: string }[] = []; - return { - grants, - mail, - router: { - routeMail: ( - _address: string, - rawMessage: string, - authenticatedSender: string, - ) => { - mail.push({ authenticatedSender, rawMessage }); - return true; - }, - sendRunGrants: ( - address: string, - _runId: string, - _stepGrants: Parameters[2], - senderIdentities: Parameters[3], - ) => { - grants.push({ address, senderIdentities }); - return true; - }, + const calls: Parameters[] = []; + const router: HookRouter = { + routeMail: (...args) => { + calls.push(args); + return true; }, }; + return { calls, router }; } -function deliverer( - router: ReturnType["router"], - systemSender: SystemSender, -) { +function deliverer(router: HookRouter, systemSender: SystemSender) { return createRunTriggerDeliverer({ router, materialize: async () => ({ outcome: "materialized", stepGrants: [] }), @@ -93,9 +71,9 @@ describe("createRunTriggerDeliverer", () => { "cron", ); - const [frame] = recorded.mail; - expect(frame?.authenticatedSender).toBe("cron@localhost"); - const raw = Buffer.from(frame?.rawMessage ?? "", "base64").toString("utf8"); + const [call] = recorded.calls; + expect(call?.[2]).toBe("cron@localhost"); + const raw = Buffer.from(call?.[1] ?? "", "base64").toString("utf8"); // The recipient rejects a valid signature worn under a different From. expect(raw).toContain("From: cron@localhost"); }); @@ -110,9 +88,9 @@ describe("createRunTriggerDeliverer", () => { undefined, ); - const [barrier] = recorded.grants; - expect(barrier?.address).toBe(ADDRESS); - expect(barrier?.senderIdentities).toEqual([ + const [call] = recorded.calls; + expect(call?.[0]).toBe(ADDRESS); + expect(call?.[4]?.senderIdentities).toEqual([ { address: "cron@localhost", publicKey: hexEncode(sender.keyPair.publicKey), @@ -137,12 +115,12 @@ describe("createRunTriggerDeliverer", () => { await deliver.to(ADDRESS, "two", "tnt_1", undefined); expect(sender.resolveCount()).toBe(2); - const keys = recorded.grants.map((g) => g.senderIdentities?.[0]?.publicKey); + const keys = recorded.calls.map( + (c) => c[4]?.senderIdentities?.[0]?.publicKey, + ); expect(keys[0]).toBe(hexEncode(sender.keyPair.publicKey)); expect(keys[1]).toBe(keys[0]); - expect( - recorded.mail.every((m) => m.authenticatedSender === "cron@localhost"), - ).toBe(true); + expect(recorded.calls.every((c) => c[2] === "cron@localhost")).toBe(true); }); test("rejects a destination that is not a run address", async () => { @@ -158,31 +136,33 @@ describe("createRunTriggerDeliverer", () => { ).rejects.toThrow("not a live run address"); }); - test("a dead run address fails with its run identity, not a bare string", async () => { + test("hands the run grants to the router in the one routeMail call", async () => { const sender = await durableSystemSender(); const recorded = recordingRouter(); - recorded.router.sendRunGrants = () => false; - const error = await deliverer(recorded.router, sender.sender) - .to(ADDRESS, "tick", "tnt_1", undefined) - .then( - () => { - throw new Error("the dead run delivered"); + const stepGrants = [{ stepId: "s1" }] as never; + const deliver = createRunTriggerDeliverer({ + router: recorded.router, + materialize: async () => ({ outcome: "materialized", stepGrants }), + tenantDomain: async () => "localhost", + senderLocalPart: "cron", + systemSender: sender.sender, + }); + await deliver.to(ADDRESS, "tick", "tnt_1", undefined); + + expect(recorded.calls).toHaveLength(1); + expect(recorded.calls[0]?.[4]).toEqual({ + runId: "run_0123456789abcdef", + stepGrants, + senderIdentities: [ + { + address: "cron@localhost", + publicKey: hexEncode(sender.keyPair.publicKey), }, - (e: unknown) => e, - ); - - expect(isRunTriggerUnroutable(error)).toBe(true); - expect((error as { code: string }).code).toBe(RUN_GRANTS_NOT_ROUTABLE); - expect((error as { address: string }).address).toBe(ADDRESS); - expect((error as { runId: string }).runId).toBe("run_0123456789abcdef"); - expect(String((error as Error).message)).toContain( - "run grants not routable", - ); - // The grants barrier never went out, so no mail follows it. - expect(recorded.mail).toEqual([]); + ], + }); }); - test("unroutable mail after delivered grants names the run too", async () => { + test("a dead run address fails with its run identity, not a bare string", async () => { const sender = await durableSystemSender(); const recorded = recordingRouter(); recorded.router.routeMail = () => false; @@ -195,12 +175,12 @@ describe("createRunTriggerDeliverer", () => { (e: unknown) => e, ); + expect(error).toBeInstanceOf(RunTriggerUnroutableError); expect(isRunTriggerUnroutable(error)).toBe(true); expect((error as { code: string }).code).toBe(RUN_MAIL_NOT_ROUTABLE); expect((error as { address: string }).address).toBe(ADDRESS); expect((error as { runId: string }).runId).toBe("run_0123456789abcdef"); - // The grants barrier went out before the mail leg failed. - expect(recorded.grants).toHaveLength(1); + expect(String((error as Error).message)).toContain("run mail not routable"); }); test("rejected grants deliver nothing", async () => { @@ -222,7 +202,6 @@ describe("createRunTriggerDeliverer", () => { await expect( deliver.to(ADDRESS, "tick", "tnt_1", undefined), ).rejects.toThrow("run grants denied"); - expect(recorded.grants).toEqual([]); - expect(recorded.mail).toEqual([]); + expect(recorded.calls).toEqual([]); }); }); diff --git a/src/deliver.ts b/src/deliver.ts index 3c5a3f4..3a84251 100644 --- a/src/deliver.ts +++ b/src/deliver.ts @@ -1,7 +1,7 @@ import { createDetachedSignatureWithSigner } from "@intx/crypto"; import { assembleMessage, assembleSignedContent } from "@intx/mime"; import type { createMailTriggeredRunGrantsMaterializer } from "@intx/hub-api"; -import type { RunGrantsFrame } from "@intx/types/sidecar"; +import type { SidecarRouter } from "@intx/hub-sessions"; import type { SystemSenderIdentity, SystemSender } from "./system-sender.js"; import { base64Encode, deriveWorkflowRunId, isRunAddress } from "@intx/types"; @@ -14,27 +14,14 @@ export type MailDeliverer = { ) => Promise; }; -export type HookMailRouter = { - routeMail: ( - address: string, - rawMessage: string, - authenticatedSender: string, - messageId?: string, - ) => boolean; - sendRunGrants: ( - address: string, - runId: string, - stepGrants: RunGrantsFrame["stepGrants"], - senderIdentities: RunGrantsFrame["senderIdentities"], - ) => boolean; -}; +export type HookRouter = Pick; export type RunTriggerMaterialize = ReturnType< typeof createMailTriggeredRunGrantsMaterializer >; export type CreateRunTriggerDelivererOpts = { - router: HookMailRouter; + router: HookRouter; materialize: RunTriggerMaterialize; tenantDomain: (tenantId: string) => Promise; /** Local part of the system sender address, e.g. "webhook" or "cron". */ @@ -44,53 +31,32 @@ export type CreateRunTriggerDelivererOpts = { }; /** `code` carried by a {@link RunTriggerUnroutableError}. */ -export const RUN_GRANTS_NOT_ROUTABLE = "run_grants_not_routable"; export const RUN_MAIL_NOT_ROUTABLE = "run_mail_not_routable"; -export type RunTriggerUnroutableCode = - | typeof RUN_GRANTS_NOT_ROUTABLE - | typeof RUN_MAIL_NOT_ROUTABLE; - /** * A system trigger the sidecar router could not route: the deployment address * has no live socket and no disconnect queue (its sidecar is gone — e.g. a * stale `running` anchor left by a previous stack). Carries the address and * run id so the caller can report a real failure and settle the dead run - * instead of logging a bare "not routable" every tick. The message keeps the - * legacy `run grants not routable` / `run mail not routable` prefix. + * instead of logging a bare "not routable" every tick. */ export class RunTriggerUnroutableError extends Error { - readonly code: RunTriggerUnroutableCode; + readonly code = RUN_MAIL_NOT_ROUTABLE; readonly address: string; readonly runId: string; - constructor(code: RunTriggerUnroutableCode, address: string, runId: string) { - super( - `${code === RUN_GRANTS_NOT_ROUTABLE ? "run grants" : "run mail"} not routable for ${address} (run ${runId})`, - ); + constructor(address: string, runId: string) { + super(`run mail not routable for ${address} (run ${runId})`); this.name = "RunTriggerUnroutableError"; - this.code = code; this.address = address; this.runId = runId; } } -/** - * Structural match for a {@link RunTriggerUnroutableError}. Structural — not - * `instanceof` — so it still matches when a consumer such as `@corbits/cron` - * resolves its own copy of this package. - */ export function isRunTriggerUnroutable( error: unknown, ): error is RunTriggerUnroutableError { - if (typeof error !== "object" || error === null) return false; - const rec = error as Record; - return ( - (rec["code"] === RUN_GRANTS_NOT_ROUTABLE || - rec["code"] === RUN_MAIL_NOT_ROUTABLE) && - typeof rec["address"] === "string" && - typeof rec["runId"] === "string" - ); + return error instanceof RunTriggerUnroutableError; } /** @@ -127,21 +93,6 @@ export function createRunTriggerDeliverer( domain, localPart: opts.senderLocalPart, }); - // Co-deliver the system sender's durable key on the grants barrier so - // the recipient can verify the trigger mail against the key the hub - // vouches for, exactly as a person-originated trigger does. - if ( - !opts.router.sendRunGrants(address, runId, grants.stepGrants, [ - { address: sender.address, publicKey: sender.publicKey }, - ]) - ) { - throw new RunTriggerUnroutableError( - RUN_GRANTS_NOT_ROUTABLE, - address, - runId, - ); - } - const raw = await assembleTriggerMail({ address, content, @@ -150,20 +101,23 @@ export function createRunTriggerDeliverer( subject, sender, }); - if ( - !opts.router.routeMail( - address, - raw.base64, - sender.address, - raw.messageId, - ) - ) { - throw new RunTriggerUnroutableError( - RUN_MAIL_NOT_ROUTABLE, - address, + // Co-deliver the system sender's durable key with the run grants so the + // recipient can verify the trigger mail against the key the hub vouches + // for, exactly as a person-originated trigger does. + const routed = await opts.router.routeMail( + address, + raw.base64, + sender.address, + raw.messageId, + { runId, - ); - } + stepGrants: grants.stepGrants, + senderIdentities: [ + { address: sender.address, publicKey: sender.publicKey }, + ], + }, + ); + if (!routed) throw new RunTriggerUnroutableError(address, runId); }, }; } diff --git a/src/index.ts b/src/index.ts index 647df10..d626cfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,14 +2,12 @@ export { createHookRoutes, type CreateHookRoutesDeps } from "./routes.js"; export { createRunTriggerDeliverer, isRunTriggerUnroutable, - RUN_GRANTS_NOT_ROUTABLE, RUN_MAIL_NOT_ROUTABLE, RunTriggerUnroutableError, type CreateRunTriggerDelivererOpts, - type HookMailRouter, + type HookRouter, type MailDeliverer, type RunTriggerMaterialize, - type RunTriggerUnroutableCode, } from "./deliver.js"; export { createTenantSystemSender, diff --git a/src/routes.ts b/src/routes.ts index ff4e02d..7ec4dcd 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -3,7 +3,7 @@ import { createGrantStore, type DB, type PrincipalKeyStore } from "@intx/db"; import { createMailTriggeredRunGrantsMaterializer } from "@intx/hub-api"; import type { CredentialCipher } from "@intx/types"; -import { createRunTriggerDeliverer, type HookMailRouter } from "./deliver.js"; +import { createRunTriggerDeliverer, type HookRouter } from "./deliver.js"; import { createHookApp } from "./hooks.js"; import { createPostgresReplayStore } from "./replay.js"; import { createTenantSystemSender } from "./system-sender.js"; @@ -13,7 +13,7 @@ export type CreateHookRoutesDeps = { db: DB["db"]; credentialCipher: CredentialCipher; principalKeyStore: PrincipalKeyStore; - router: HookMailRouter; + router: HookRouter; }; /**