Skip to content

Commit 4ec1222

Browse files
refactor!: deliver run grants and trigger mail in one routeMail call (#25)
BREAKING CHANGE: `HookMailRouter` is replaced by `HookRouter` (`Pick<SidecarRouter, "routeMail">`); `RUN_GRANTS_NOT_ROUTABLE` and `RunTriggerUnroutableCode` are removed and `RunTriggerUnroutableError` takes `(address, runId)`. `@intx/hub-sessions` is a new peer dependency.
1 parent cdc6c6d commit 4ec1222

10 files changed

Lines changed: 99 additions & 161 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ storage of its own, or scheduling (`@corbits/cron`).
2626
its own run principal, `deriveRunPrincipalId(tenantId, runId)`, through
2727
Interchange's mail-triggered grant path, never as the credential owner.
2828
- Trigger mail is signed by the durable per-tenant system sender
29-
(`webhook@domain`), whose key is co-delivered on the run-grants barrier.
29+
(`webhook@domain`), whose key is co-delivered with the run grants in the single `routeMail` call.
3030
- No unsigned mode: a credential without a verifier matches no hook.
3131
- Unknown hooks and name collisions both answer `404`.
3232
- `exactOptionalPropertyTypes` is on: omit optional keys, never assign

‎README.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ Signatures are checked over the raw request bytes, and the body is forwarded unc
6161
| `createRunTriggerDeliverer(opts)` | Delivers trigger mail to a live run as its run principal. |
6262
| `createTenantSystemSender({ db, principalKeyStore })` | Durable per-tenant sender (`<localPart>@domain`) that signs trigger mail. |
6363
| `isRunTriggerUnroutable(error)` | Narrows to `{ code, address, runId }` when the router has no route. |
64-
| `RUN_GRANTS_NOT_ROUTABLE`, `RUN_MAIL_NOT_ROUTABLE` | The two `code` values. |
64+
| `RUN_MAIL_NOT_ROUTABLE` | The error `code`. |
6565
| `RunTriggerUnroutableError` | The error `isRunTriggerUnroutable` matches. |
66-
| `HookMailRouter` | Router type the host passes in: `routeMail` and `sendRunGrants`. |
66+
| `HookRouter` | `Pick<SidecarRouter, "routeMail">`, the router the host passes in. |
6767
| `MailDeliverer` | `{ to(address, content, tenantId, subject) }`, what the deliverer returns. |
6868

6969
### `runWebhookMigrations(dbConfig, { schema })`
@@ -78,7 +78,7 @@ Run `runWebhookMigrations` at hub start, after `runMigrations`, then build the r
7878
import { createEnvKeyCredentialCipher } from "@intx/crypto";
7979
import { createDB, createPrincipalKeyStore, runMigrations } from "@intx/db";
8080
import { hexDecode } from "@intx/types";
81-
import { createHookRoutes, type HookMailRouter } from "@corbits/webhooks";
81+
import { createHookRoutes, type HookRouter } from "@corbits/webhooks";
8282
import { runWebhookMigrations } from "@corbits/webhooks/migrations";
8383

8484
const dbConfig = {
@@ -93,7 +93,7 @@ await runWebhookMigrations(dbConfig, { schema: "public" });
9393

9494
const { db } = createDB(dbConfig);
9595

96-
export const hookRoutes = (router: HookMailRouter) =>
96+
export const hookRoutes = (router: HookRouter) =>
9797
createHookRoutes({
9898
db,
9999
credentialCipher: createEnvKeyCredentialCipher(
@@ -143,6 +143,13 @@ curl -X POST "$HUB/api/hooks/crd_…" \
143143

144144
The hub answers `202 { "ok": true, "to": "run_…@acme.example" }` and the run starts.
145145

146+
## Upgrading from 0.2
147+
148+
- `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.
149+
- 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.
150+
- `isRunTriggerUnroutable` uses `instanceof`; every consumer must resolve one copy of `@corbits/webhooks`.
151+
- `@intx/hub-sessions` is a new peer dependency.
152+
146153
## Upgrading from 0.1
147154

148155
- `installWebhooks(opts)` is removed. Call `app.route("/api/hooks", createHookRoutes(deps))` with the same `db`, `credentialCipher`, `principalKeyStore` and `router`; drop `app` from the options.

‎bun.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎e2e/helpers.ts‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import { createEnvKeyCredentialCipher } from "@intx/crypto";
1111
import { principal, tenant } from "@intx/db/schema";
1212

13-
import { createHookRoutes, type HookMailRouter } from "../src/index.js";
13+
import { createHookRoutes, type HookRouter } from "../src/index.js";
1414
import { runWebhookMigrations } from "../src/migrations.js";
1515

1616
const PG_ENV = [
@@ -95,21 +95,16 @@ export const testCredentialCipher = createEnvKeyCredentialCipher(
9595
new Uint8Array(32).fill(7),
9696
);
9797

98-
/** A sidecar router that records every frame and routes all of them. */
98+
/** A sidecar router that records every call and routes all of them. */
9999
export function recordingRouter() {
100-
const grants: Parameters<HookMailRouter["sendRunGrants"]>[] = [];
101-
const mail: Parameters<HookMailRouter["routeMail"]>[] = [];
102-
const router: HookMailRouter = {
103-
sendRunGrants: (...args) => {
104-
grants.push(args);
105-
return true;
106-
},
100+
const mail: Parameters<HookRouter["routeMail"]>[] = [];
101+
const router: HookRouter = {
107102
routeMail: (...args) => {
108103
mail.push(args);
109104
return true;
110105
},
111106
};
112-
return { router, grants, mail };
107+
return { router, mail };
113108
}
114109

115110
/**
@@ -118,7 +113,7 @@ export function recordingRouter() {
118113
*/
119114
export function mountHookRoutes(opts: {
120115
db: TestDb["db"];
121-
router: HookMailRouter;
116+
router: HookRouter;
122117
}): Hono {
123118
const app = new Hono();
124119
app.route(

‎e2e/hooks.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ describe.skipIf(!harnessDbAvailable())(
172172
});
173173

174174
expect(res.status).toBe(401);
175-
expect(recorded.grants).toEqual([]);
175+
expect(recorded.mail).toEqual([]);
176176
expect(recorded.mail).toEqual([]);
177177
const { runPrincipals, grants } = await runGrants(db());
178178
expect(runPrincipals).toEqual([]);
@@ -198,10 +198,10 @@ describe.skipIf(!harnessDbAvailable())(
198198
expect(grants.map((g) => [g.principalId, g.resource, g.action])).toEqual([
199199
[runPrincipal?.id ?? "", "tool:read_file", "invoke"],
200200
]);
201-
const [frame] = recorded.grants;
202-
expect(frame?.[0]).toBe(ADDRESS);
203-
expect(frame?.[2]).toHaveLength(1);
204201
expect(recorded.mail).toHaveLength(1);
202+
const [call] = recorded.mail;
203+
expect(call?.[0]).toBe(ADDRESS);
204+
expect(call?.[4]?.stepGrants).toHaveLength(1);
205205
});
206206

207207
test("a replayed delivery is 409 on every replica", async () => {

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@intx/db": "0.4.0",
5858
"@intx/hub-api": "0.4.0",
5959
"@intx/hub-common": "0.4.0",
60+
"@intx/hub-sessions": "0.4.0",
6061
"@intx/mime": "0.4.0",
6162
"@intx/types": "0.4.0",
6263
"@types/bun": "1.3.9",
@@ -72,6 +73,7 @@
7273
"@intx/db": "^0.4.0",
7374
"@intx/hub-api": "^0.4.0",
7475
"@intx/hub-common": "^0.4.0",
76+
"@intx/hub-sessions": "^0.4.0",
7577
"@intx/mime": "^0.4.0",
7678
"@intx/types": "^0.4.0",
7779
"drizzle-orm": "^0.45.1",

‎src/deliver.test.ts‎

Lines changed: 45 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { hexEncode } from "@intx/types";
55
import {
66
createRunTriggerDeliverer,
77
isRunTriggerUnroutable,
8+
RUN_MAIL_NOT_ROUTABLE,
9+
RunTriggerUnroutableError,
810
} from "./deliver.js";
9-
import { RUN_GRANTS_NOT_ROUTABLE, RUN_MAIL_NOT_ROUTABLE } from "./deliver.js";
10-
import type { HookMailRouter } from "./deliver.js";
11+
import type { HookRouter } from "./deliver.js";
1112
import type { SystemSender } from "./system-sender.js";
1213

1314
const ADDRESS = "run_0123456789abcdef@localhost";
@@ -39,40 +40,17 @@ async function durableSystemSender() {
3940
}
4041

4142
function recordingRouter() {
42-
const grants: {
43-
address: string;
44-
senderIdentities: Parameters<HookMailRouter["sendRunGrants"]>[3];
45-
}[] = [];
46-
const mail: { authenticatedSender: string; rawMessage: string }[] = [];
47-
return {
48-
grants,
49-
mail,
50-
router: {
51-
routeMail: (
52-
_address: string,
53-
rawMessage: string,
54-
authenticatedSender: string,
55-
) => {
56-
mail.push({ authenticatedSender, rawMessage });
57-
return true;
58-
},
59-
sendRunGrants: (
60-
address: string,
61-
_runId: string,
62-
_stepGrants: Parameters<HookMailRouter["sendRunGrants"]>[2],
63-
senderIdentities: Parameters<HookMailRouter["sendRunGrants"]>[3],
64-
) => {
65-
grants.push({ address, senderIdentities });
66-
return true;
67-
},
43+
const calls: Parameters<HookRouter["routeMail"]>[] = [];
44+
const router: HookRouter = {
45+
routeMail: (...args) => {
46+
calls.push(args);
47+
return true;
6848
},
6949
};
50+
return { calls, router };
7051
}
7152

72-
function deliverer(
73-
router: ReturnType<typeof recordingRouter>["router"],
74-
systemSender: SystemSender,
75-
) {
53+
function deliverer(router: HookRouter, systemSender: SystemSender) {
7654
return createRunTriggerDeliverer({
7755
router,
7856
materialize: async () => ({ outcome: "materialized", stepGrants: [] }),
@@ -93,9 +71,9 @@ describe("createRunTriggerDeliverer", () => {
9371
"cron",
9472
);
9573

96-
const [frame] = recorded.mail;
97-
expect(frame?.authenticatedSender).toBe("cron@localhost");
98-
const raw = Buffer.from(frame?.rawMessage ?? "", "base64").toString("utf8");
74+
const [call] = recorded.calls;
75+
expect(call?.[2]).toBe("cron@localhost");
76+
const raw = Buffer.from(call?.[1] ?? "", "base64").toString("utf8");
9977
// The recipient rejects a valid signature worn under a different From.
10078
expect(raw).toContain("From: cron@localhost");
10179
});
@@ -110,9 +88,9 @@ describe("createRunTriggerDeliverer", () => {
11088
undefined,
11189
);
11290

113-
const [barrier] = recorded.grants;
114-
expect(barrier?.address).toBe(ADDRESS);
115-
expect(barrier?.senderIdentities).toEqual([
91+
const [call] = recorded.calls;
92+
expect(call?.[0]).toBe(ADDRESS);
93+
expect(call?.[4]?.senderIdentities).toEqual([
11694
{
11795
address: "cron@localhost",
11896
publicKey: hexEncode(sender.keyPair.publicKey),
@@ -137,12 +115,12 @@ describe("createRunTriggerDeliverer", () => {
137115
await deliver.to(ADDRESS, "two", "tnt_1", undefined);
138116

139117
expect(sender.resolveCount()).toBe(2);
140-
const keys = recorded.grants.map((g) => g.senderIdentities?.[0]?.publicKey);
118+
const keys = recorded.calls.map(
119+
(c) => c[4]?.senderIdentities?.[0]?.publicKey,
120+
);
141121
expect(keys[0]).toBe(hexEncode(sender.keyPair.publicKey));
142122
expect(keys[1]).toBe(keys[0]);
143-
expect(
144-
recorded.mail.every((m) => m.authenticatedSender === "cron@localhost"),
145-
).toBe(true);
123+
expect(recorded.calls.every((c) => c[2] === "cron@localhost")).toBe(true);
146124
});
147125

148126
test("rejects a destination that is not a run address", async () => {
@@ -158,31 +136,33 @@ describe("createRunTriggerDeliverer", () => {
158136
).rejects.toThrow("not a live run address");
159137
});
160138

161-
test("a dead run address fails with its run identity, not a bare string", async () => {
139+
test("hands the run grants to the router in the one routeMail call", async () => {
162140
const sender = await durableSystemSender();
163141
const recorded = recordingRouter();
164-
recorded.router.sendRunGrants = () => false;
165-
const error = await deliverer(recorded.router, sender.sender)
166-
.to(ADDRESS, "tick", "tnt_1", undefined)
167-
.then(
168-
() => {
169-
throw new Error("the dead run delivered");
142+
const stepGrants = [{ stepId: "s1" }] as never;
143+
const deliver = createRunTriggerDeliverer({
144+
router: recorded.router,
145+
materialize: async () => ({ outcome: "materialized", stepGrants }),
146+
tenantDomain: async () => "localhost",
147+
senderLocalPart: "cron",
148+
systemSender: sender.sender,
149+
});
150+
await deliver.to(ADDRESS, "tick", "tnt_1", undefined);
151+
152+
expect(recorded.calls).toHaveLength(1);
153+
expect(recorded.calls[0]?.[4]).toEqual({
154+
runId: "run_0123456789abcdef",
155+
stepGrants,
156+
senderIdentities: [
157+
{
158+
address: "cron@localhost",
159+
publicKey: hexEncode(sender.keyPair.publicKey),
170160
},
171-
(e: unknown) => e,
172-
);
173-
174-
expect(isRunTriggerUnroutable(error)).toBe(true);
175-
expect((error as { code: string }).code).toBe(RUN_GRANTS_NOT_ROUTABLE);
176-
expect((error as { address: string }).address).toBe(ADDRESS);
177-
expect((error as { runId: string }).runId).toBe("run_0123456789abcdef");
178-
expect(String((error as Error).message)).toContain(
179-
"run grants not routable",
180-
);
181-
// The grants barrier never went out, so no mail follows it.
182-
expect(recorded.mail).toEqual([]);
161+
],
162+
});
183163
});
184164

185-
test("unroutable mail after delivered grants names the run too", async () => {
165+
test("a dead run address fails with its run identity, not a bare string", async () => {
186166
const sender = await durableSystemSender();
187167
const recorded = recordingRouter();
188168
recorded.router.routeMail = () => false;
@@ -195,12 +175,12 @@ describe("createRunTriggerDeliverer", () => {
195175
(e: unknown) => e,
196176
);
197177

178+
expect(error).toBeInstanceOf(RunTriggerUnroutableError);
198179
expect(isRunTriggerUnroutable(error)).toBe(true);
199180
expect((error as { code: string }).code).toBe(RUN_MAIL_NOT_ROUTABLE);
200181
expect((error as { address: string }).address).toBe(ADDRESS);
201182
expect((error as { runId: string }).runId).toBe("run_0123456789abcdef");
202-
// The grants barrier went out before the mail leg failed.
203-
expect(recorded.grants).toHaveLength(1);
183+
expect(String((error as Error).message)).toContain("run mail not routable");
204184
});
205185

206186
test("rejected grants deliver nothing", async () => {
@@ -222,7 +202,6 @@ describe("createRunTriggerDeliverer", () => {
222202
await expect(
223203
deliver.to(ADDRESS, "tick", "tnt_1", undefined),
224204
).rejects.toThrow("run grants denied");
225-
expect(recorded.grants).toEqual([]);
226-
expect(recorded.mail).toEqual([]);
205+
expect(recorded.calls).toEqual([]);
227206
});
228207
});

0 commit comments

Comments
 (0)