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
25 changes: 22 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
19 changes: 17 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "./memory.ts";
import {
registerMemoryRoutes,
type CallerResolver,
type RouteDeps,
} from "./routes/mount.ts";

Expand Down Expand Up @@ -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 & {
/**
Expand All @@ -222,6 +228,13 @@ export type CreateMemoryOptions = MemoryOptions & {
* `createResolveTenant` on `/api/tenants/:tenantId/*`.
*/
app?: Hono<TenantEnv>;
/**
* 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;
};

/**
Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -274,6 +288,7 @@ export function createMemory(options: CreateMemoryOptions): Memory {
memory,
requireGrant,
grants,
...(callerResolver !== undefined ? { callerResolver } : {}),
};
registerMemoryRoutes(app, deps);
}
Expand Down
8 changes: 7 additions & 1 deletion src/routes/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -36,6 +41,7 @@ export function mountAddRoute(app: Hono<TenantEnv>, deps: RouteDeps): void {
502: { description: "add failed" },
},
}),
resolveCaller(deps),
requirePrincipal(),
grantGuard(deps, "add"),
validator("json", AddRequest),
Expand Down
160 changes: 160 additions & 0 deletions src/routes/deps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
caller,
grantGuard,
requirePrincipal,
resolveCaller,
type ResolvedCaller,
type RouteDeps,
} from "./deps.ts";

Expand Down Expand Up @@ -115,3 +117,161 @@ grantGuard(deps(grantsWith(), requireGrant), "add");
expect(called).toEqual({ resource: "memory", action: "add" });
});
});

describe("resolveCaller", () => {
function fakeContext(): {
ctx: Context<TenantEnv>;
sets: Record<string, unknown>;
jsonCalls: { body: unknown; status: number }[];
} {
const sets: Record<string, unknown> = {};
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<TenantEnv>;
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);
});
});
Loading
Loading