Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<localPart>@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<SidecarRouter, "routeMail">`, the router the host passes in. |
| `MailDeliverer` | `{ to(address, content, tenantId, subject) }`, what the deliverer returns. |

### `runWebhookMigrations(dbConfig, { schema })`
Expand All @@ -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 = {
Expand All @@ -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(
Expand Down Expand Up @@ -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<SidecarRouter, "routeMail">` 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.
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 6 additions & 11 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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<HookMailRouter["sendRunGrants"]>[] = [];
const mail: Parameters<HookMailRouter["routeMail"]>[] = [];
const router: HookMailRouter = {
sendRunGrants: (...args) => {
grants.push(args);
return true;
},
const mail: Parameters<HookRouter["routeMail"]>[] = [];
const router: HookRouter = {
routeMail: (...args) => {
mail.push(args);
return true;
},
};
return { router, grants, mail };
return { router, mail };
}

/**
Expand All @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions e2e/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand All @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
111 changes: 45 additions & 66 deletions src/deliver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -39,40 +40,17 @@ async function durableSystemSender() {
}

function recordingRouter() {
const grants: {
address: string;
senderIdentities: Parameters<HookMailRouter["sendRunGrants"]>[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<HookMailRouter["sendRunGrants"]>[2],
senderIdentities: Parameters<HookMailRouter["sendRunGrants"]>[3],
) => {
grants.push({ address, senderIdentities });
return true;
},
const calls: Parameters<HookRouter["routeMail"]>[] = [];
const router: HookRouter = {
routeMail: (...args) => {
calls.push(args);
return true;
},
};
return { calls, router };
}

function deliverer(
router: ReturnType<typeof recordingRouter>["router"],
systemSender: SystemSender,
) {
function deliverer(router: HookRouter, systemSender: SystemSender) {
return createRunTriggerDeliverer({
router,
materialize: async () => ({ outcome: "materialized", stepGrants: [] }),
Expand All @@ -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");
});
Expand All @@ -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),
Expand All @@ -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 () => {
Expand All @@ -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;
Expand All @@ -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 () => {
Expand All @@ -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([]);
});
});
Loading
Loading