Skip to content

Commit 7449350

Browse files
Add a host-supplied caller resolver for machine callers (CL-6286)
Routes previously assumed identity arrived via c.get("principal") from browser session middleware, so a workflow-process child with only a bearer token and run address could not reach memory. Adds an optional host-supplied CallerResolver. Unset, behavior is byte-identical to before. Set, the resolved caller is parsed at the trust boundary with arktype (non-blank tenantId/principalId), seated on context, and traverses the identical grant path a browser caller does — never a weaker one. Identity never rides in a request body. A Proxy-based canary test asserts the authz path reads nothing beyond .id/.tenantId on the fabricated rows, so a future Interchange bump that starts reading another field fails the suite instead of silently authorizing on placeholder data. AGENTS.md now freezes ResolvedCaller at exactly { tenantId, principalId }. Hosts that delete a hand-rolled parallel surface must re-home its rate limiter and payload cap as their own middleware first.
1 parent 9e6f213 commit 7449350

13 files changed

Lines changed: 823 additions & 14 deletions

File tree

‎AGENTS.md‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,28 @@ CI runs `typecheck` + `test` — both must pass before any push.
3535

3636
## Non-negotiable invariants
3737

38-
1. **Authenticate nothing.** Identity is `c.get("principal")` from the
39-
Interchange context; authorization goes through the host's grant store
40-
(`@intx/authz`). Never add API keys, sessions, or OAuth here.
38+
1. **Authenticate nothing.** Identity defaults to `c.get("principal")` from
39+
the Interchange context; a host may instead supply `callerResolver`
40+
(`src/routes/deps.ts`) to resolve a non-browser caller (e.g. a
41+
workflow-run child's own sidecar bearer token) — but resolving that
42+
token is 100% host logic, called through the seam, never implemented
43+
here. Either way authorization goes through the host's grant store
44+
(`@intx/authz`) via the same `requireGrant` path. Never add API keys,
45+
sessions, or OAuth here.
46+
47+
`ResolvedCaller` (the `callerResolver` return type) is frozen at exactly
48+
`{ tenantId, principalId }`. It carries no roles, no grants, no
49+
authorization hints of any kind — it is a shape conversion (host identity
50+
in, context principal/tenant out), never an authorization decision. A
51+
resolved caller traverses the identical `requireGrant`/`grantGuard` path a
52+
browser caller does and can never bypass it. Before widening this type —
53+
"let it carry roles too," "let a trusted caller skip `grantGuard`" — stop:
54+
either change turns the conversion shim into the library making an
55+
authorization decision, which IS the invariant this rule exists to name.
56+
If a host needs richer machine-caller authorization, that logic belongs in
57+
the host's own grant store / `callerResolver` closure, resolved down to
58+
`{ tenantId, principalId }` before it ever reaches this package — not in a
59+
wider `ResolvedCaller`.
4160
2. **One Postgres**: `DATABASE_URL`, the engine's own vector plane, under the
4261
`memory` schema — never the host's control-plane DB. No foreign keys into
4362
control-plane tables; cross-refs (`tenant_id`, `principal_id`) are plain

‎ARCHITECTURE.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,15 @@ helpers are optional multi-writer / backfill — not the primary path.
4545
- **Runtime**: Bun + Hono, mounted on the host app. **DB**: own pgvector
4646
Postgres (`DATABASE_URL`) unless `documentStore` is injected.
4747
**Types**: arktype at every route boundary.
48-
- **No auth of its own.** Interchange resolves the caller and puts `principal`
49-
+ `tenant` on context; routes read identity from there
50-
(`tenantId = principal.tenantId`, `principalId = principal.id`).
48+
- **No auth of its own.** By default, Interchange resolves the caller and
49+
puts `principal` + `tenant` on context; routes read identity from there
50+
(`tenantId = principal.tenantId`, `principalId = principal.id`). A host
51+
with a non-browser caller (e.g. a workflow-run child with its own sidecar
52+
bearer token) may instead pass `callerResolver` (`RouteDeps` /
53+
`createMemory`) — the host still does 100% of the authenticating, it just
54+
hands the resolved `{ tenantId, principalId }` in through the seam instead
55+
of setting context itself. Either way the resolved identity, never
56+
anything from the request body, is what `grantGuard` authorizes.
5157
- **Grants delegate to the host.** Pass `grantStore` + `conditionRegistry`;
5258
routes use `createRequireGrant("memory", action)`.
5359
- **Dependencies**: `@intx/hub-api`, `@intx/authz`, `@intx/log`, Hono, Drizzle,

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `RouteDeps.callerResolver` / `createMemory({ callerResolver })` — an
13+
optional host-supplied resolver from a request to a `{ tenantId,
14+
principalId }` scope, for a caller that never goes through the host's
15+
tenant-session middleware (e.g. a workflow-run child authenticating with
16+
its own sidecar bearer token). Unset by default: every route still reads
17+
identity from `c.get("principal")` exactly as before. When set, the
18+
resolved identity is seated as the request's principal/tenant ahead of
19+
`grantGuard`, so the same `requireGrant` authorization path applies to a
20+
machine caller — never a separate, weaker one. Identity from the resolver
21+
always wins over anything a request body claims. The resolver's return
22+
value is parsed with arktype (non-empty `tenantId`/`principalId`); a
23+
resolver returning a malformed identity is rejected with `500` (a host
24+
bug), never seated as a garbage scope.
25+
1026
### Fixed
1127

1228
- Feed `nextCursor` advances past the examined raw page after grant-tag

‎IMPLEMENTATION.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,23 @@ principal read off the Interchange context (`caller(c)` →
582582
Each route is guarded with `grantGuard(deps, action)`, which applies the host's
583583
`requireGrant("memory", action)` when provided (else a pass-through).
584584

585+
**Machine callers (CL-6286):** `RouteDeps.callerResolver` /
586+
`createMemory({ callerResolver })` lets a host resolve identity for a caller
587+
that never goes through its tenant-session middleware — e.g. a workflow-run
588+
child authenticating with its own sidecar bearer token. Unset by default
589+
(every existing host is unaffected). When set, `resolveCaller` (`deps.ts`)
590+
runs ahead of `requirePrincipal`/`grantGuard`, calls the resolver, parses its
591+
return with arktype (non-empty `tenantId`/`principalId` — a malformed
592+
resolver return is a host bug and gets `500`, not `401`), and seats the
593+
result as the context `principal`/`tenant` so the exact same
594+
`requireGrant`/`authorize` path a browser caller gets applies to the machine
595+
caller too. **Migrating a host off a hand-rolled parallel surface** (like a
596+
`createWorkflowMemoryRoutes`-shaped workaround) onto `callerResolver`: that
597+
kind of surface commonly also carries a per-run write-rate limiter and a
598+
request payload cap that this package does not implement (see CL-6286's PR
599+
body for why) — re-home both as host middleware before deleting the old
600+
surface, or a migrating host silently loses them.
601+
585602
| Method + path | Grant action | Request body | Response |
586603
|---|---|---|---|
587604
| `POST /api/tenants/:tenantId/memory/add` | `add` | `{ title, text, access_tags?, share? }` | `200 { documentId, versionId }`; `400` on validation |

‎src/index.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from "./memory.ts";
2323
import {
2424
registerMemoryRoutes,
25+
type CallerResolver,
2526
type RouteDeps,
2627
} from "./routes/mount.ts";
2728

@@ -212,7 +213,12 @@ export {
212213
} from "./core/fts-language.ts";
213214

214215
// Granular HTTP composition (most hosts use createMemory({ app, … }) instead)
215-
export { registerMemoryRoutes, type GrantConfig } from "./routes/mount.ts";
216+
export {
217+
registerMemoryRoutes,
218+
type CallerResolver,
219+
type GrantConfig,
220+
type ResolvedCaller,
221+
} from "./routes/mount.ts";
216222

217223
export type CreateMemoryOptions = MemoryOptions & {
218224
/**
@@ -222,6 +228,13 @@ export type CreateMemoryOptions = MemoryOptions & {
222228
* `createResolveTenant` on `/api/tenants/:tenantId/*`.
223229
*/
224230
app?: Hono<TenantEnv>;
231+
/**
232+
* Resolver for a caller that never goes through the host's tenant-session
233+
* middleware — e.g. a workflow-run child authenticating with its own
234+
* sidecar bearer token. Unset by default: every route reads identity from
235+
* `c.get("principal")` exactly as before. See `CallerResolver`.
236+
*/
237+
callerResolver?: CallerResolver;
225238
};
226239

227240
/**
@@ -251,7 +264,8 @@ export type CreateMemoryOptions = MemoryOptions & {
251264
* ```
252265
*/
253266
export function createMemory(options: CreateMemoryOptions): Memory {
254-
const { app, grantStore, conditionRegistry, ...planeOpts } = options;
267+
const { app, callerResolver, grantStore, conditionRegistry, ...planeOpts } =
268+
options;
255269
const grants = resolveGrantConfig({
256270
...(grantStore !== undefined ? { grantStore } : {}),
257271
...(conditionRegistry !== undefined ? { conditionRegistry } : {}),
@@ -274,6 +288,7 @@ export function createMemory(options: CreateMemoryOptions): Memory {
274288
memory,
275289
requireGrant,
276290
grants,
291+
...(callerResolver !== undefined ? { callerResolver } : {}),
277292
};
278293
registerMemoryRoutes(app, deps);
279294
}

‎src/routes/add.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import { AddRequest } from "../http-bodies.ts";
99

1010
import { MemoryError } from "../memory.ts";
1111
import type { RouteDeps } from "./deps.ts";
12-
import { caller, grantGuard, requirePrincipal } from "./deps.ts";
12+
import {
13+
caller,
14+
grantGuard,
15+
requirePrincipal,
16+
resolveCaller,
17+
} from "./deps.ts";
1318

1419
const AddResponse = type({
1520
documentId: "string",
@@ -36,6 +41,7 @@ export function mountAddRoute(app: Hono<TenantEnv>, deps: RouteDeps): void {
3641
502: { description: "add failed" },
3742
},
3843
}),
44+
resolveCaller(deps),
3945
requirePrincipal(),
4046
grantGuard(deps, "add"),
4147
validator("json", AddRequest),

‎src/routes/deps.test.ts‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
caller,
99
grantGuard,
1010
requirePrincipal,
11+
resolveCaller,
12+
type ResolvedCaller,
1113
type RouteDeps,
1214
} from "./deps.ts";
1315

@@ -115,3 +117,161 @@ grantGuard(deps(grantsWith(), requireGrant), "add");
115117
expect(called).toEqual({ resource: "memory", action: "add" });
116118
});
117119
});
120+
121+
describe("resolveCaller", () => {
122+
function fakeContext(): {
123+
ctx: Context<TenantEnv>;
124+
sets: Record<string, unknown>;
125+
jsonCalls: { body: unknown; status: number }[];
126+
} {
127+
const sets: Record<string, unknown> = {};
128+
const jsonCalls: { body: unknown; status: number }[] = [];
129+
const ctx = {
130+
get: (k: string) => sets[k],
131+
set: (k: string, v: unknown) => {
132+
sets[k] = v;
133+
},
134+
json: (body: unknown, status: number) => {
135+
jsonCalls.push({ body, status });
136+
return { body, status };
137+
},
138+
} as unknown as Context<TenantEnv>;
139+
return { ctx, sets, jsonCalls };
140+
}
141+
142+
test("is a no-op passthrough when no callerResolver is configured", async () => {
143+
const { ctx, sets, jsonCalls } = fakeContext();
144+
let nextCalled = false;
145+
await resolveCaller(deps(grantsWith()))(ctx, async () => {
146+
nextCalled = true;
147+
});
148+
expect(nextCalled).toBe(true);
149+
expect(jsonCalls).toHaveLength(0);
150+
expect(sets.principal).toBeUndefined();
151+
expect(sets.tenant).toBeUndefined();
152+
});
153+
154+
test("seats the resolved tenant/principal on the context and calls next()", async () => {
155+
const { ctx, sets, jsonCalls } = fakeContext();
156+
const resolved: ResolvedCaller = {
157+
tenantId: "tenant-run",
158+
principalId: "run-principal",
159+
};
160+
const routeDeps: RouteDeps = {
161+
...deps(grantsWith()),
162+
callerResolver: () => resolved,
163+
};
164+
let nextCalled = false;
165+
await resolveCaller(routeDeps)(ctx, async () => {
166+
nextCalled = true;
167+
});
168+
expect(nextCalled).toBe(true);
169+
expect(jsonCalls).toHaveLength(0);
170+
expect(sets.principal).toMatchObject({
171+
id: "run-principal",
172+
tenantId: "tenant-run",
173+
});
174+
expect(sets.tenant).toMatchObject({ id: "tenant-run" });
175+
expect(caller(ctx)).toEqual({
176+
scopeId: "tenant-run",
177+
subjectId: "run-principal",
178+
});
179+
});
180+
181+
test("responds 401 and never calls next() when the resolver rejects the request", async () => {
182+
const { ctx, sets, jsonCalls } = fakeContext();
183+
const routeDeps: RouteDeps = {
184+
...deps(grantsWith()),
185+
callerResolver: () => null,
186+
};
187+
let nextCalled = false;
188+
await resolveCaller(routeDeps)(ctx, async () => {
189+
nextCalled = true;
190+
});
191+
expect(nextCalled).toBe(false);
192+
expect(jsonCalls).toHaveLength(1);
193+
expect(jsonCalls[0]?.status).toBe(401);
194+
expect(jsonCalls[0]?.body).toMatchObject({
195+
error: { code: "unauthorized" },
196+
});
197+
expect(sets.principal).toBeUndefined();
198+
});
199+
200+
test("supports an async callerResolver", async () => {
201+
const { ctx, sets } = fakeContext();
202+
const routeDeps: RouteDeps = {
203+
...deps(grantsWith()),
204+
callerResolver: async () => ({
205+
tenantId: "tenant-async",
206+
principalId: "principal-async",
207+
}),
208+
};
209+
await resolveCaller(routeDeps)(ctx, async () => {});
210+
expect(sets.principal).toMatchObject({ id: "principal-async" });
211+
});
212+
213+
test("rejects an empty-string tenantId/principalId with 500, never seating it", async () => {
214+
const { ctx, sets, jsonCalls } = fakeContext();
215+
const routeDeps: RouteDeps = {
216+
...deps(grantsWith()),
217+
callerResolver: () => ({ tenantId: "", principalId: "" }),
218+
};
219+
let nextCalled = false;
220+
await resolveCaller(routeDeps)(ctx, async () => {
221+
nextCalled = true;
222+
});
223+
expect(nextCalled).toBe(false);
224+
expect(jsonCalls).toHaveLength(1);
225+
expect(jsonCalls[0]?.status).toBe(500);
226+
expect(jsonCalls[0]?.body).toMatchObject({
227+
error: { code: "invalid_resolved_caller" },
228+
});
229+
expect(sets.principal).toBeUndefined();
230+
expect(sets.tenant).toBeUndefined();
231+
});
232+
233+
test("rejects a whitespace-only tenantId/principalId with 500, never seating it", async () => {
234+
// "string >= 1" is a LENGTH constraint -- " " has length 1 and would
235+
// pass it. This is the same class of bug PR #34 fixed in optionalEnv
236+
// (v.length > 0 accepted " "); this test is the regression guard for
237+
// it at this boundary.
238+
const { ctx, sets, jsonCalls } = fakeContext();
239+
const routeDeps: RouteDeps = {
240+
...deps(grantsWith()),
241+
callerResolver: () => ({ tenantId: " ", principalId: "\t\n" }),
242+
};
243+
let nextCalled = false;
244+
await resolveCaller(routeDeps)(ctx, async () => {
245+
nextCalled = true;
246+
});
247+
expect(nextCalled).toBe(false);
248+
expect(jsonCalls).toHaveLength(1);
249+
expect(jsonCalls[0]?.status).toBe(500);
250+
expect(jsonCalls[0]?.body).toMatchObject({
251+
error: { code: "invalid_resolved_caller" },
252+
});
253+
expect(sets.principal).toBeUndefined();
254+
expect(sets.tenant).toBeUndefined();
255+
});
256+
257+
test("rejects a resolved value missing principalId with 500", async () => {
258+
const { ctx, jsonCalls } = fakeContext();
259+
const routeDeps: RouteDeps = {
260+
...deps(grantsWith()),
261+
// Cast past the type system the way a buggy host's JS resolver would.
262+
callerResolver: () => ({ tenantId: "tenant-run" }) as unknown as ResolvedCaller,
263+
};
264+
await resolveCaller(routeDeps)(ctx, async () => {});
265+
expect(jsonCalls[0]?.status).toBe(500);
266+
});
267+
268+
test("rejects a non-object resolved value with 500", async () => {
269+
const { ctx, jsonCalls } = fakeContext();
270+
const routeDeps: RouteDeps = {
271+
...deps(grantsWith()),
272+
callerResolver: () => "tenant-run" as unknown as ResolvedCaller,
273+
};
274+
await resolveCaller(routeDeps)(ctx, async () => {});
275+
expect(jsonCalls[0]?.status).toBe(500);
276+
});
277+
});

0 commit comments

Comments
 (0)