diff --git a/AGENTS.md b/AGENTS.md index 35e3107..9a01dcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,9 +35,28 @@ CI runs `typecheck` + `test` — both must pass before any push. ## Non-negotiable invariants -1. **Authenticate nothing.** Identity is `c.get("principal")` from the - Interchange context; authorization goes through the host's grant store - (`@intx/authz`). Never add API keys, sessions, or OAuth here. +1. **Authenticate nothing.** Identity defaults to `c.get("principal")` from + the Interchange context; a host may instead supply `callerResolver` + (`src/routes/deps.ts`) to resolve a non-browser caller (e.g. a + workflow-run child's own sidecar bearer token) — but resolving that + token is 100% host logic, called through the seam, never implemented + here. Either way authorization goes through the host's grant store + (`@intx/authz`) via the same `requireGrant` path. Never add API keys, + sessions, or OAuth here. + + `ResolvedCaller` (the `callerResolver` return type) is frozen at exactly + `{ tenantId, principalId }`. It carries no roles, no grants, no + authorization hints of any kind — it is a shape conversion (host identity + in, context principal/tenant out), never an authorization decision. A + resolved caller traverses the identical `requireGrant`/`grantGuard` path a + browser caller does and can never bypass it. Before widening this type — + "let it carry roles too," "let a trusted caller skip `grantGuard`" — stop: + either change turns the conversion shim into the library making an + authorization decision, which IS the invariant this rule exists to name. + If a host needs richer machine-caller authorization, that logic belongs in + the host's own grant store / `callerResolver` closure, resolved down to + `{ tenantId, principalId }` before it ever reaches this package — not in a + wider `ResolvedCaller`. 2. **One Postgres**: `DATABASE_URL`, the engine's own vector plane, under the `memory` schema — never the host's control-plane DB. No foreign keys into control-plane tables; cross-refs (`tenant_id`, `principal_id`) are plain diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 95d91b3..896f330 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -45,9 +45,15 @@ helpers are optional multi-writer / backfill — not the primary path. - **Runtime**: Bun + Hono, mounted on the host app. **DB**: own pgvector Postgres (`DATABASE_URL`) unless `documentStore` is injected. **Types**: arktype at every route boundary. -- **No auth of its own.** Interchange resolves the caller and puts `principal` - + `tenant` on context; routes read identity from there - (`tenantId = principal.tenantId`, `principalId = principal.id`). +- **No auth of its own.** By default, Interchange resolves the caller and + puts `principal` + `tenant` on context; routes read identity from there + (`tenantId = principal.tenantId`, `principalId = principal.id`). A host + with a non-browser caller (e.g. a workflow-run child with its own sidecar + bearer token) may instead pass `callerResolver` (`RouteDeps` / + `createMemory`) — the host still does 100% of the authenticating, it just + hands the resolved `{ tenantId, principalId }` in through the seam instead + of setting context itself. Either way the resolved identity, never + anything from the request body, is what `grantGuard` authorizes. - **Grants delegate to the host.** Pass `grantStore` + `conditionRegistry`; routes use `createRequireGrant("memory", action)`. - **Dependencies**: `@intx/hub-api`, `@intx/authz`, `@intx/log`, Hono, Drizzle, diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d944a9..f24e7ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `RouteDeps.callerResolver` / `createMemory({ callerResolver })` — an + optional host-supplied resolver from a request to a `{ tenantId, + principalId }` scope, for a caller that never goes through the host's + tenant-session middleware (e.g. a workflow-run child authenticating with + its own sidecar bearer token). Unset by default: every route still reads + identity from `c.get("principal")` exactly as before. When set, the + resolved identity is seated as the request's principal/tenant ahead of + `grantGuard`, so the same `requireGrant` authorization path applies to a + machine caller — never a separate, weaker one. Identity from the resolver + always wins over anything a request body claims. The resolver's return + value is parsed with arktype (non-empty `tenantId`/`principalId`); a + resolver returning a malformed identity is rejected with `500` (a host + bug), never seated as a garbage scope. + ### Fixed - Feed `nextCursor` advances past the examined raw page after grant-tag diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 75c7b6d..3f60ec5 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -582,6 +582,23 @@ principal read off the Interchange context (`caller(c)` → Each route is guarded with `grantGuard(deps, action)`, which applies the host's `requireGrant("memory", action)` when provided (else a pass-through). +**Machine callers (CL-6286):** `RouteDeps.callerResolver` / +`createMemory({ callerResolver })` lets a host resolve identity for a caller +that never goes through its tenant-session middleware — e.g. a workflow-run +child authenticating with its own sidecar bearer token. Unset by default +(every existing host is unaffected). When set, `resolveCaller` (`deps.ts`) +runs ahead of `requirePrincipal`/`grantGuard`, calls the resolver, parses its +return with arktype (non-empty `tenantId`/`principalId` — a malformed +resolver return is a host bug and gets `500`, not `401`), and seats the +result as the context `principal`/`tenant` so the exact same +`requireGrant`/`authorize` path a browser caller gets applies to the machine +caller too. **Migrating a host off a hand-rolled parallel surface** (like a +`createWorkflowMemoryRoutes`-shaped workaround) onto `callerResolver`: that +kind of surface commonly also carries a per-run write-rate limiter and a +request payload cap that this package does not implement (see CL-6286's PR +body for why) — re-home both as host middleware before deleting the old +surface, or a migrating host silently loses them. + | Method + path | Grant action | Request body | Response | |---|---|---|---| | `POST /api/tenants/:tenantId/memory/add` | `add` | `{ title, text, access_tags?, share? }` | `200 { documentId, versionId }`; `400` on validation | diff --git a/src/index.ts b/src/index.ts index 4e16314..1ddc32f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { } from "./memory.ts"; import { registerMemoryRoutes, + type CallerResolver, type RouteDeps, } from "./routes/mount.ts"; @@ -212,7 +213,12 @@ export { } from "./core/fts-language.ts"; // Granular HTTP composition (most hosts use createMemory({ app, … }) instead) -export { registerMemoryRoutes, type GrantConfig } from "./routes/mount.ts"; +export { + registerMemoryRoutes, + type CallerResolver, + type GrantConfig, + type ResolvedCaller, +} from "./routes/mount.ts"; export type CreateMemoryOptions = MemoryOptions & { /** @@ -222,6 +228,13 @@ export type CreateMemoryOptions = MemoryOptions & { * `createResolveTenant` on `/api/tenants/:tenantId/*`. */ app?: Hono; + /** + * Resolver for a caller that never goes through the host's tenant-session + * middleware — e.g. a workflow-run child authenticating with its own + * sidecar bearer token. Unset by default: every route reads identity from + * `c.get("principal")` exactly as before. See `CallerResolver`. + */ + callerResolver?: CallerResolver; }; /** @@ -251,7 +264,8 @@ export type CreateMemoryOptions = MemoryOptions & { * ``` */ export function createMemory(options: CreateMemoryOptions): Memory { - const { app, grantStore, conditionRegistry, ...planeOpts } = options; + const { app, callerResolver, grantStore, conditionRegistry, ...planeOpts } = + options; const grants = resolveGrantConfig({ ...(grantStore !== undefined ? { grantStore } : {}), ...(conditionRegistry !== undefined ? { conditionRegistry } : {}), @@ -274,6 +288,7 @@ export function createMemory(options: CreateMemoryOptions): Memory { memory, requireGrant, grants, + ...(callerResolver !== undefined ? { callerResolver } : {}), }; registerMemoryRoutes(app, deps); } diff --git a/src/routes/add.ts b/src/routes/add.ts index f484175..a00c6ed 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -9,7 +9,12 @@ import { AddRequest } from "../http-bodies.ts"; import { MemoryError } from "../memory.ts"; import type { RouteDeps } from "./deps.ts"; -import { caller, grantGuard, requirePrincipal } from "./deps.ts"; +import { + caller, + grantGuard, + requirePrincipal, + resolveCaller, +} from "./deps.ts"; const AddResponse = type({ documentId: "string", @@ -36,6 +41,7 @@ export function mountAddRoute(app: Hono, deps: RouteDeps): void { 502: { description: "add failed" }, }, }), + resolveCaller(deps), requirePrincipal(), grantGuard(deps, "add"), validator("json", AddRequest), diff --git a/src/routes/deps.test.ts b/src/routes/deps.test.ts index 0f4e2c2..6765369 100644 --- a/src/routes/deps.test.ts +++ b/src/routes/deps.test.ts @@ -8,6 +8,8 @@ import { caller, grantGuard, requirePrincipal, + resolveCaller, + type ResolvedCaller, type RouteDeps, } from "./deps.ts"; @@ -115,3 +117,161 @@ grantGuard(deps(grantsWith(), requireGrant), "add"); expect(called).toEqual({ resource: "memory", action: "add" }); }); }); + +describe("resolveCaller", () => { + function fakeContext(): { + ctx: Context; + sets: Record; + jsonCalls: { body: unknown; status: number }[]; + } { + const sets: Record = {}; + const jsonCalls: { body: unknown; status: number }[] = []; + const ctx = { + get: (k: string) => sets[k], + set: (k: string, v: unknown) => { + sets[k] = v; + }, + json: (body: unknown, status: number) => { + jsonCalls.push({ body, status }); + return { body, status }; + }, + } as unknown as Context; + return { ctx, sets, jsonCalls }; + } + + test("is a no-op passthrough when no callerResolver is configured", async () => { + const { ctx, sets, jsonCalls } = fakeContext(); + let nextCalled = false; + await resolveCaller(deps(grantsWith()))(ctx, async () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(jsonCalls).toHaveLength(0); + expect(sets.principal).toBeUndefined(); + expect(sets.tenant).toBeUndefined(); + }); + + test("seats the resolved tenant/principal on the context and calls next()", async () => { + const { ctx, sets, jsonCalls } = fakeContext(); + const resolved: ResolvedCaller = { + tenantId: "tenant-run", + principalId: "run-principal", + }; + const routeDeps: RouteDeps = { + ...deps(grantsWith()), + callerResolver: () => resolved, + }; + let nextCalled = false; + await resolveCaller(routeDeps)(ctx, async () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(jsonCalls).toHaveLength(0); + expect(sets.principal).toMatchObject({ + id: "run-principal", + tenantId: "tenant-run", + }); + expect(sets.tenant).toMatchObject({ id: "tenant-run" }); + expect(caller(ctx)).toEqual({ + scopeId: "tenant-run", + subjectId: "run-principal", + }); + }); + + test("responds 401 and never calls next() when the resolver rejects the request", async () => { + const { ctx, sets, jsonCalls } = fakeContext(); + const routeDeps: RouteDeps = { + ...deps(grantsWith()), + callerResolver: () => null, + }; + let nextCalled = false; + await resolveCaller(routeDeps)(ctx, async () => { + nextCalled = true; + }); + expect(nextCalled).toBe(false); + expect(jsonCalls).toHaveLength(1); + expect(jsonCalls[0]?.status).toBe(401); + expect(jsonCalls[0]?.body).toMatchObject({ + error: { code: "unauthorized" }, + }); + expect(sets.principal).toBeUndefined(); + }); + + test("supports an async callerResolver", async () => { + const { ctx, sets } = fakeContext(); + const routeDeps: RouteDeps = { + ...deps(grantsWith()), + callerResolver: async () => ({ + tenantId: "tenant-async", + principalId: "principal-async", + }), + }; + await resolveCaller(routeDeps)(ctx, async () => {}); + expect(sets.principal).toMatchObject({ id: "principal-async" }); + }); + + test("rejects an empty-string tenantId/principalId with 500, never seating it", async () => { + const { ctx, sets, jsonCalls } = fakeContext(); + const routeDeps: RouteDeps = { + ...deps(grantsWith()), + callerResolver: () => ({ tenantId: "", principalId: "" }), + }; + let nextCalled = false; + await resolveCaller(routeDeps)(ctx, async () => { + nextCalled = true; + }); + expect(nextCalled).toBe(false); + expect(jsonCalls).toHaveLength(1); + expect(jsonCalls[0]?.status).toBe(500); + expect(jsonCalls[0]?.body).toMatchObject({ + error: { code: "invalid_resolved_caller" }, + }); + expect(sets.principal).toBeUndefined(); + expect(sets.tenant).toBeUndefined(); + }); + + test("rejects a whitespace-only tenantId/principalId with 500, never seating it", async () => { + // "string >= 1" is a LENGTH constraint -- " " has length 1 and would + // pass it. This is the same class of bug PR #34 fixed in optionalEnv + // (v.length > 0 accepted " "); this test is the regression guard for + // it at this boundary. + const { ctx, sets, jsonCalls } = fakeContext(); + const routeDeps: RouteDeps = { + ...deps(grantsWith()), + callerResolver: () => ({ tenantId: " ", principalId: "\t\n" }), + }; + let nextCalled = false; + await resolveCaller(routeDeps)(ctx, async () => { + nextCalled = true; + }); + expect(nextCalled).toBe(false); + expect(jsonCalls).toHaveLength(1); + expect(jsonCalls[0]?.status).toBe(500); + expect(jsonCalls[0]?.body).toMatchObject({ + error: { code: "invalid_resolved_caller" }, + }); + expect(sets.principal).toBeUndefined(); + expect(sets.tenant).toBeUndefined(); + }); + + test("rejects a resolved value missing principalId with 500", async () => { + const { ctx, jsonCalls } = fakeContext(); + const routeDeps: RouteDeps = { + ...deps(grantsWith()), + // Cast past the type system the way a buggy host's JS resolver would. + callerResolver: () => ({ tenantId: "tenant-run" }) as unknown as ResolvedCaller, + }; + await resolveCaller(routeDeps)(ctx, async () => {}); + expect(jsonCalls[0]?.status).toBe(500); + }); + + test("rejects a non-object resolved value with 500", async () => { + const { ctx, jsonCalls } = fakeContext(); + const routeDeps: RouteDeps = { + ...deps(grantsWith()), + callerResolver: () => "tenant-run" as unknown as ResolvedCaller, + }; + await resolveCaller(routeDeps)(ctx, async () => {}); + expect(jsonCalls[0]?.status).toBe(500); + }); +}); diff --git a/src/routes/deps.ts b/src/routes/deps.ts index 5e4f1e4..c5fdbd4 100644 --- a/src/routes/deps.ts +++ b/src/routes/deps.ts @@ -1,7 +1,14 @@ import type { Context, MiddlewareHandler } from "hono"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; +import type { + PrincipalRow, + RequireGrant, + TenantEnv, + TenantRow, +} from "@intx/hub-api"; import type { ConditionRegistry, GrantStore } from "@intx/authz"; +import { type } from "arktype"; +import { log } from "../log.ts"; import type { Memory } from "../memory.ts"; /** @@ -13,12 +20,66 @@ export type GrantConfig = { conditionRegistry: ConditionRegistry; }; +/** + * A caller identity resolved by the host outside its browser/API-session + * tenant middleware — e.g. a workflow-run child authenticating with its own + * sidecar bearer token. Always wins over anything a request body claims. + */ +export type ResolvedCaller = { + tenantId: string; + principalId: string; +}; + +/** + * Host-supplied resolver from a request to a `ResolvedCaller`, for callers + * that never go through the host's tenant-session middleware. Return `null` + * when the request cannot be authenticated. The package never authenticates + * this itself — the resolver is 100% host logic (bearer-token lookup, run + * address lookup, whatever the host's transport is). + */ +export type CallerResolver = ( + c: Context, +) => ResolvedCaller | null | Promise; + +/** + * `"string >= 1"` is a LENGTH constraint, not a content one — `" "` has + * length 1 and would pass it, seating a whitespace-only scope exactly like + * the empty-string case this schema exists to reject. Require at least one + * non-whitespace character instead. + */ +const NonBlankId = type("string").narrow( + (s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank (not just whitespace)"), +); + +/** + * The one boundary where a host hands this package an identity, so it is + * parsed like any other trust boundary (AGENTS.md invariant 4) rather than + * trusted as an opaque TS shape. A resolver returning `{tenantId: "", + * principalId: ""}` or `{tenantId: " ", principalId: " "}` (or anything not + * matching this shape) is rejected here, never seated as a "valid" + * empty/blank scope. + */ +const ResolvedCallerSchema = type({ + tenantId: NonBlankId, + principalId: NonBlankId, +}); + export type RouteDeps = { memory: Memory; /** Route-guard middleware factory (Interchange `createRequireGrant`). */ requireGrant: RequireGrant; /** The grant store, kept for callers that need imperative checks. */ grants: GrantConfig; + /** + * Optional resolver for a non-browser caller. Unset by default: every + * route reads identity from `c.get("principal")` exactly as before, so no + * existing host is affected. When set, it runs ahead of + * `requirePrincipal`/`grantGuard` and its result becomes the context + * principal/tenant those two already read — grant checks apply to a + * machine caller through the same `requireGrant` path a browser caller + * gets, never a separate weaker one. + */ + callerResolver?: CallerResolver; }; /** Identity for the current request, read from the Interchange context. */ @@ -78,3 +139,92 @@ export function grantGuard( ): MiddlewareHandler { return deps.requireGrant("memory", action); } + +/** + * `requireGrant`/`authorize` only ever read `.id` off the tenant/principal + * rows they're handed; the rest of `PrincipalRow`/`TenantRow` describes a + * browser-session database row a bearer-token caller has none of. These + * placeholders exist only to satisfy that shape. + */ +function principalRowFor(resolved: ResolvedCaller): PrincipalRow { + return { + id: resolved.principalId, + tenantId: resolved.tenantId, + kind: "agent", + refId: resolved.principalId, + status: "active", + createdAt: new Date(0), + updatedAt: new Date(0), + }; +} + +function tenantRowFor(resolved: ResolvedCaller): TenantRow { + return { + id: resolved.tenantId, + name: resolved.tenantId, + slug: resolved.tenantId, + domain: "", + parentId: null, + config: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }; +} + +/** + * When `deps.callerResolver` is set, resolve the caller and seat it as the + * context principal/tenant before `requirePrincipal`/`grantGuard` run — a + * request the resolver rejects never reaches them. When unset, this is a + * no-op passthrough; the host's own tenant-session middleware remains the + * only thing that ever sets `principal`/`tenant`, exactly as before this + * ticket. + * + * `null` means "this caller could not be authenticated" — a caller problem, + * so 401. A resolved value that fails `ResolvedCallerSchema` means the + * host's own resolver is broken (empty strings, wrong types, wrong shape) — + * a host bug, not a caller's, so 500 rather than 401: the request is not + * "unauthorized," the identity provider is misbehaving. + */ +export function resolveCaller(deps: RouteDeps): MiddlewareHandler { + return async (c, next) => { + if (!deps.callerResolver) { + await next(); + return; + } + const resolved = await deps.callerResolver(c); + if (!resolved) { + return c.json( + { + error: { + code: "unauthorized", + message: + "The configured caller resolver could not identify this " + + "request (missing or unrecognized credentials).", + }, + }, + 401, + ); + } + const parsed = ResolvedCallerSchema(resolved); + if (parsed instanceof type.errors) { + log.error( + `memory: callerResolver returned a malformed identity: ${parsed.summary}`, + ); + return c.json( + { + error: { + code: "invalid_resolved_caller", + message: + "The configured caller resolver returned an identity that " + + "does not match { tenantId, principalId } (non-empty " + + "strings). This is a host misconfiguration.", + }, + }, + 500, + ); + } + c.set("principal", principalRowFor(parsed)); + c.set("tenant", tenantRowFor(parsed)); + await next(); + }; +} diff --git a/src/routes/feed.ts b/src/routes/feed.ts index 3f64abd..de9605b 100644 --- a/src/routes/feed.ts +++ b/src/routes/feed.ts @@ -7,7 +7,12 @@ import { formatCaughtError, log } from "../log.ts"; import { FeedQuery, parseFeedQuery } from "../http-bodies.ts"; import { MemoryError } from "../memory.ts"; import type { RouteDeps } from "./deps.ts"; -import { caller, grantGuard, requirePrincipal } from "./deps.ts"; +import { + caller, + grantGuard, + requirePrincipal, + resolveCaller, +} from "./deps.ts"; const FeedResponse = type({ entries: type({ @@ -48,6 +53,7 @@ export function mountFeedRoute(app: Hono, deps: RouteDeps): void { 502: { description: "Feed query failed" }, }, }), + resolveCaller(deps), requirePrincipal(), grantGuard(deps, "search"), validator("query", FeedQuery), diff --git a/src/routes/list.ts b/src/routes/list.ts index b07a6b5..921d80e 100644 --- a/src/routes/list.ts +++ b/src/routes/list.ts @@ -11,7 +11,12 @@ import { LIST_LIMIT_MIN, } from "../memory.ts"; import type { RouteDeps } from "./deps.ts"; -import { caller, grantGuard, requirePrincipal } from "./deps.ts"; +import { + caller, + grantGuard, + requirePrincipal, + resolveCaller, +} from "./deps.ts"; const ListResponse = type({ events: type({ @@ -43,6 +48,7 @@ export function mountListRoute(app: Hono, deps: RouteDeps): void { 502: { description: "List query failed" }, }, }), + resolveCaller(deps), requirePrincipal(), grantGuard(deps, "search"), validator("query", ListQuery), diff --git a/src/routes/mount.ts b/src/routes/mount.ts index bc1a760..48ff69e 100644 --- a/src/routes/mount.ts +++ b/src/routes/mount.ts @@ -2,6 +2,13 @@ * Register memory HTTP routes on a host Interchange app. * Prefer `createMemory({ app, … })` unless you need to compose routes yourself. * (MCP lives in the standalone @corbitsdev/hono-openapi-mcp bridge.) + * + * The `:tenantId` in `/api/tenants/:tenantId/memory/*` is never read by any + * handler — it exists only so the route shares a path shape with the rest + * of the host's `/api/tenants/:tenantId/*` tree. Every scope actually comes + * from `caller(c)` (context `principal`/`tenant`, set by the host's + * tenant-session middleware or, for a machine caller, by `resolveCaller` + * from `RouteDeps.callerResolver`) — never the URL. */ import type { Hono } from "hono"; import type { TenantEnv } from "@intx/hub-api"; @@ -12,7 +19,12 @@ import { mountSearchRoute } from "./search.ts"; import { mountListRoute } from "./list.ts"; import { mountFeedRoute } from "./feed.ts"; -export type { GrantConfig, RouteDeps } from "./deps.ts"; +export type { + CallerResolver, + GrantConfig, + ResolvedCaller, + RouteDeps, +} from "./deps.ts"; /** HTTP JSON routes: add, search, list, feed. */ export function registerMemoryRoutes( diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index 70bd434..028bd7b 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -114,6 +114,85 @@ function buildApp( return { app, added, searched }; } +/** + * A plane stub for the machine-caller tests that records `tenantId`/ + * `principalId` on every verb (not just `add`) — the shared `stubPlane` + * above deliberately omits them from `search`/`list` to keep its existing + * `.toEqual` assertions exact, so this is a separate stub rather than a + * change to that one. + */ +function stubMachinePlane(opts?: { + timelineCatalog?: Array< + TimelineEvent & { visibleTo: readonly string[] | "tenant" } + >; +}) { + const added: { title: string; tenantId: string; principalId: string }[] = []; + const searched: { tenantId: string; principalId: string; query: string }[] = + []; + const fed: { tenantId: string; principalId: string }[] = []; + const catalog = opts?.timelineCatalog ?? []; + const plane: Memory = { + search: async (p) => { + searched.push({ + tenantId: p.tenantId, + principalId: p.principalId, + query: p.query, + }); + return { items: [], evidence: "none" }; + }, + add: async (p) => { + added.push({ + title: p.content?.title ?? "", + tenantId: p.tenantId, + principalId: p.principalId, + }); + return { documentId: "doc-stub", versionId: "ver-stub" }; + }, + list: async (p) => { + return catalog + .filter( + (e) => + e.tenantId === p.tenantId && + (e.visibleTo === "tenant" || e.visibleTo.includes(p.principalId)), + ) + .map(({ visibleTo: _v, ...event }) => event); + }, + feed: async (p) => { + fed.push({ tenantId: p.tenantId, principalId: p.principalId }); + return { entries: [], nextCursor: null }; + }, + close: async () => {}, + }; + return { plane, added, searched, fed }; +} + +function buildAppWithCallerResolver( + grants: GrantRule[], + callerResolver: RouteDeps["callerResolver"], + opts?: { + timelineCatalog?: Array< + TimelineEvent & { visibleTo: readonly string[] | "tenant" } + >; + }, +) { + const { plane, added, searched, fed } = stubMachinePlane(opts); + const grantConfig = { + grantStore: createInMemoryGrantStore(grants), + conditionRegistry: {}, + }; + const deps: RouteDeps = { + memory: plane, + grants: grantConfig, + requireGrant: createRequireGrant(grantConfig), + ...(callerResolver !== undefined ? { callerResolver } : {}), + }; + // No tenant-session middleware mounted at all — a machine caller has no + // browser session; `callerResolver` is the only source of identity here. + const app = new Hono(); + registerMemoryRoutes(app, deps); + return { app, added, searched, fed }; +} + function buildAppWithoutPrincipal() { const { plane } = stubPlane(); const grantConfig = { @@ -338,3 +417,314 @@ describe("memory HTTP routes", () => { expect(res.status).toBe(401); }); }); + +describe("memory HTTP routes — machine caller (callerResolver)", () => { + const RUN_TENANT = "tenant-run"; + const RUN_PRINCIPAL = "run-principal"; + + test("add with the add grant writes under the resolved run's scope", async () => { + const { app, added } = buildAppWithCallerResolver( + [grant(RUN_PRINCIPAL, "add")], + () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), + ); + const res = await app.request( + "/api/tenants/t1/memory/add", + jsonPost({ title: "t", text: "body" }), + ); + expect(res.status).toBe(200); + expect(added).toEqual([ + { title: "t", tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }, + ]); + }); + + test("a request body naming a different tenant/principal is ignored — the resolved caller always wins", async () => { + const { app, added } = buildAppWithCallerResolver( + [grant(RUN_PRINCIPAL, "add")], + () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), + ); + const res = await app.request( + "/api/tenants/t1/memory/add", + jsonPost({ + title: "t", + text: "body", + tenantId: "tenant-evil", + principalId: "attacker", + }), + ); + expect(res.status).toBe(200); + expect(added).toEqual([ + { title: "t", tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }, + ]); + }); + + test("grantGuard still applies to a machine caller: no grant is 403", async () => { + const { app, added } = buildAppWithCallerResolver( + [], + () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), + ); + const res = await app.request( + "/api/tenants/t1/memory/add", + jsonPost({ title: "t", text: "body" }), + ); + expect(res.status).toBe(403); + expect(added).toHaveLength(0); + }); + + test("a grant for a different principal does not authorize this machine caller", async () => { + const { app, added } = buildAppWithCallerResolver( + [grant("some-other-principal", "add")], + () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), + ); + const res = await app.request( + "/api/tenants/t1/memory/add", + jsonPost({ title: "t", text: "body" }), + ); + expect(res.status).toBe(403); + expect(added).toHaveLength(0); + }); + + test("an unresolvable caller is 401, never falls through to a browser principal", async () => { + const { app } = buildAppWithCallerResolver( + [grant(RUN_PRINCIPAL, "add")], + () => null, + ); + const res = await app.request( + "/api/tenants/t1/memory/add", + jsonPost({ title: "t", text: "body" }), + ); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("unauthorized"); + }); + + // The read paths matter more than `add` here: a bug on these means reading + // ANOTHER TENANT'S memories, not just misattributing a write. + + test("search threads the resolved tenant/principal through to the plane, ignoring the URL's :tenantId", async () => { + const { app, searched } = buildAppWithCallerResolver( + [grant(RUN_PRINCIPAL, "search")], + () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), + ); + // The path names a DIFFERENT tenant than the resolved caller's. + const res = await app.request( + "/api/tenants/tenant-in-url-not-resolved/memory/search", + jsonPost({ query: "hello" }), + ); + expect(res.status).toBe(200); + expect(searched).toEqual([ + { tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL, query: "hello" }, + ]); + }); + + test("search requires the search grant for the resolved caller (403)", async () => { + const { app, searched } = buildAppWithCallerResolver( + [], + () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), + ); + const res = await app.request( + "/api/tenants/t1/memory/search", + jsonPost({ query: "hello" }), + ); + expect(res.status).toBe(403); + expect(searched).toHaveLength(0); + }); + + test("list returns only the resolved tenant's events, never another tenant's, regardless of the URL's :tenantId", async () => { + const OTHER_TENANT = "tenant-other"; + const catalog: Array = [ + { + at: "2026-01-02T00:00:00.000Z", + title: "run tenant's note", + source: "mcp", + tenantId: RUN_TENANT, + principalId: RUN_PRINCIPAL, + visibleTo: "tenant", + }, + { + at: "2026-01-01T00:00:00.000Z", + title: "a different tenant's note", + source: "mcp", + tenantId: OTHER_TENANT, + principalId: "someone-else", + visibleTo: "tenant", + }, + ]; + const { app } = buildAppWithCallerResolver( + [grant(RUN_PRINCIPAL, "search")], + () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), + { timelineCatalog: catalog }, + ); + // The path names the OTHER tenant; the resolved caller must still only + // ever see its own tenant's events. + const res = await app.request(`/api/tenants/${OTHER_TENANT}/memory/list`); + expect(res.status).toBe(200); + const body = (await res.json()) as { events: TimelineEvent[] }; + expect(body.events.map((e) => e.title)).toEqual(["run tenant's note"]); + }); + + test("list requires the search grant for the resolved caller (403)", async () => { + const { app } = buildAppWithCallerResolver( + [], + () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), + ); + const res = await app.request("/api/tenants/t1/memory/list"); + expect(res.status).toBe(403); + }); + + test("feed threads the resolved tenant/principal through to the plane, ignoring the URL's :tenantId", async () => { + const { app, fed } = buildAppWithCallerResolver( + [grant(RUN_PRINCIPAL, "search")], + () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), + ); + const res = await app.request( + "/api/tenants/tenant-in-url-not-resolved/memory/feed", + ); + expect(res.status).toBe(200); + expect(fed).toEqual([{ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }]); + }); + + test("feed requires the search grant for the resolved caller (403)", async () => { + const { app, fed } = buildAppWithCallerResolver( + [], + () => ({ tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }), + ); + const res = await app.request("/api/tenants/t1/memory/feed"); + expect(res.status).toBe(403); + expect(fed).toHaveLength(0); + }); +}); + +describe("memory HTTP routes — resolver trust-boundary and row-fabrication contract", () => { + const RUN_TENANT = "tenant-run"; + const RUN_PRINCIPAL = "run-principal"; + + test("a resolver that throws does not authorize the request (fails closed, no leaked message)", async () => { + const { app, added } = buildAppWithCallerResolver( + [grant(RUN_PRINCIPAL, "add")], + () => { + throw new Error("db connection string with secret=abc123"); + }, + ); + const res = await app.request( + "/api/tenants/t1/memory/add", + jsonPost({ title: "t", text: "body" }), + ); + expect(res.status).toBe(500); + const text = await res.text(); + expect(text).not.toContain("secret=abc123"); + expect(added).toHaveLength(0); + }); + + test.each([ + ["empty-string", { tenantId: "", principalId: "" }], + // "string >= 1" would be a LENGTH constraint only -- " " has length 1 + // and would pass it, seating a whitespace-only scope wearing the same + // costume as the empty-string case above. This is the regression guard + // for that boundary (same bug class PR #34 fixed in optionalEnv). + ["whitespace-only", { tenantId: " ", principalId: "\t\n" }], + ] as const)( + "a resolver returning a %s identity is rejected, not seated as a garbage scope", + async (_label, resolved) => { + // A grant that would (wrongly) authorize the malformed principal if + // the identity were ever seated — proving rejection happens before + // grantGuard, not that no grant happened to match. + const { app, added } = buildAppWithCallerResolver( + [grant(resolved.principalId, "add")], + () => resolved, + ); + const res = await app.request( + "/api/tenants/t1/memory/add", + jsonPost({ title: "t", text: "body" }), + ); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("invalid_resolved_caller"); + expect(added).toHaveLength(0); + }, + ); + + /** + * `principalRowFor`/`tenantRowFor` (deps.ts) fabricate `PrincipalRow`/ + * `TenantRow` with placeholder fields for everything Interchange's + * `requireGrant`/`authorize` doesn't read. That's only safe as long as + * `authorize()` (and `caller()`, in-package) never read anything but + * `.id` / `.tenantId`. A Proxy that throws on any other property access + * turns a future Interchange version quietly reading a fabricated field + * (`status`, `kind`, `parentId`, `config`, ...) into a loud test failure + * instead of silent authorization on fiction. + */ + test("requireGrant/authorize and caller() never read a fabricated row field beyond .id / .tenantId", async () => { + function canary(row: T, allowed: (keyof T)[]): T { + return new Proxy(row, { + get(target, prop, receiver) { + if ( + typeof prop === "string" && + !allowed.includes(prop as keyof T) + ) { + throw new Error( + `unexpected field access on a synthesized row: ${prop}`, + ); + } + return Reflect.get(target, prop, receiver); + }, + }); + } + + const canaryPrincipal = canary( + { + id: RUN_PRINCIPAL, + tenantId: RUN_TENANT, + kind: "agent" as const, + refId: RUN_PRINCIPAL, + status: "active" as const, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + ["id", "tenantId"], + ); + const canaryTenant = canary( + { + id: RUN_TENANT, + name: RUN_TENANT, + slug: RUN_TENANT, + domain: "", + parentId: null, + config: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + ["id"], + ); + + const { plane, added } = stubMachinePlane(); + const grantConfig = { + grantStore: createInMemoryGrantStore([grant(RUN_PRINCIPAL, "add")]), + conditionRegistry: {}, + }; + const deps: RouteDeps = { + memory: plane, + grants: grantConfig, + requireGrant: createRequireGrant(grantConfig), + }; + const app = new Hono(); + // Seat the canary rows directly, bypassing resolveCaller/callerResolver: + // this isolates the contract under test (does anything downstream of + // context read more than .id / .tenantId) from resolveCaller's own + // fabrication, which is exercised separately above. + app.use("*", async (c, next) => { + c.set("principal", canaryPrincipal); + c.set("tenant", canaryTenant); + await next(); + }); + registerMemoryRoutes(app, deps); + + const res = await app.request( + "/api/tenants/t1/memory/add", + jsonPost({ title: "t", text: "body" }), + ); + expect(res.status).toBe(200); + expect(added).toEqual([ + { title: "t", tenantId: RUN_TENANT, principalId: RUN_PRINCIPAL }, + ]); + }); +}); diff --git a/src/routes/search.ts b/src/routes/search.ts index 68edab0..e08f68a 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -7,7 +7,12 @@ import { formatCaughtError, log } from "../log.ts"; import { SearchRequest } from "../http-bodies.ts"; import { MemoryError } from "../memory.ts"; import type { RouteDeps } from "./deps.ts"; -import { caller, grantGuard, requirePrincipal } from "./deps.ts"; +import { + caller, + grantGuard, + requirePrincipal, + resolveCaller, +} from "./deps.ts"; // `kinds`/`entity_ids` scope every retrieval channel — see the // `kinds`/`entityIds` doc comments on MemorySearchParams (memory.ts) @@ -57,6 +62,7 @@ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { 502: { description: "search failed" }, }, }), + resolveCaller(deps), requirePrincipal(), grantGuard(deps, "search"), validator("json", SearchRequest),