diff --git a/AGENTS.md b/AGENTS.md index fb30a06..0e4be9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ CI runs `typecheck` + `test` — both must pass before any push. ## Layout -- `src/index.ts` — public surface: `createMemory` (optional `app` registers HTTP), `registerMemoryRoutes` +- `src/index.ts` — public surface: `createMemory`, `createMemoryRoutes` - `src/mount-config.ts` / `src/config.ts` — mount config + engine config - `src/routes/` — Hono routes (`add`, `search`, `list`, `feed`, retention diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cfbe8ad..cafe3da 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,9 +15,10 @@ The store was detachable from a larger backend, then mountable: - Document access is Interchange grant tags on the row (`accessTags` + creator), not a private ACL engine inside this package. -It ships as `createMemory({ app, … })`: the host passes its Hono app and grant -store; the library registers routes, reads identity from request context, and -talks to its DocumentStore. No second server. +It ships as `createMemory(…)` plus `createMemoryRoutes(deps)`: the host builds +the plane, mounts the returned Hono sub-app, and passes its `requireGrant`; the +routes read identity from request context and talk to the DocumentStore. No +second server. ## Product path @@ -53,8 +54,8 @@ helpers are optional multi-writer / backfill — not the primary path. 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 + bearer token) may instead pass `callerResolver` to `createMemoryRoutes` + — 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. @@ -96,7 +97,7 @@ exposes the same three verbs. ## Mounted surface -`createMemory({ app })` registers: +`createMemoryRoutes(deps)`, mounted at `/api/tenants/:tenantId/memory`, serves: - `POST /api/tenants/:tenantId/memory/add` — ingest (raw + derive on the default store). - `POST /api/tenants/:tenantId/memory/search` — hybrid retrieval (FTS + dense → RRF → rerank → diff --git a/CHANGELOG.md b/CHANGELOG.md index f086e13..3db258e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `MEMORY_GRANT_REQUIREMENTS` is read from `package.json` `interchange.grantRequirements`, now the only declaration. `MEMORY_CAPABILITY_IDS` is typed `string[]`. +- `createMemoryRoutes({ memory, requireGrant, callerResolver? })` returns the + memory routes as a `Hono` sub-app with paths relative to its + mount point; hosts mount it at `/api/tenants/:tenantId/memory`. It replaces + `createMemory({ app })` and `registerMemoryRoutes`, and `RouteDeps` no + longer carries `grants`. `createMemory` only builds the plane. +- The package root exports only the public API. Internal services and + helpers (transform, retention, feed, share materialization, corroboration, + embed model registry, degrade metrics, FTS helpers), the test fakes, and + `resolveGrantConfig` are no longer exported. The distiller stays at + `@corbits/memory/distiller` and migrations at `@corbits/memory/migrations`. ### Added @@ -29,7 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 in-process `Memory`. New `memory:forget` / `memory:purge` grant requirements (`source: "creator"`) and `capabilityIdsForSurface()` so distiller/tools installs no longer pick up routes-only capabilities by accident. -- `RouteDeps.callerResolver` / `createMemory({ callerResolver })` — an +- `RouteDeps.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 diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 544be8f..7a5ddbb 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -8,7 +8,7 @@ and wire shapes. For the "why standalone" / boundaries story, read ``` src/ - index.ts # createMemory / registerMemoryRoutes / mountWorkflowMemory + distiller re-exports + index.ts # createMemory / createMemoryRoutes / mountWorkflowMemory mount-config.ts # MemoryConfig + loadMemoryConfig() — the mount config config.ts # EngineConfig — the core vector-plane config (db + embed + rerank) @@ -23,7 +23,7 @@ src/ migrations.ts # runMemoryMigrations(url) ports/ # DocumentStore / SourceProvider + fakes routes/ # the mounted tenant routes - mount.ts # registerMemoryRoutes (HTTP) + mount.ts # createMemoryRoutes (HTTP sub-app) deps.ts # RouteDeps, caller(c) (context identity), grantGuard add.ts, search.ts, list.ts, feed.ts @@ -605,7 +605,8 @@ end-user agents. ## Mounted routes -`createMemory({ app })` registers these onto the host app. Identity is the request +`createMemoryRoutes(deps)` serves these once the host mounts it at +`/api/tenants/:tenantId/memory`. Identity is the request principal read off the Interchange context (`caller(c)` → `{ scopeId: principal.tenantId, subjectId: principal.id }`); clients never send @@ -613,8 +614,8 @@ 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 +**Machine callers (CL-6286):** `RouteDeps.callerResolver` (passed to +`createMemoryRoutes`) 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`) @@ -640,7 +641,7 @@ surface, or a migrating host silently loses them. | `POST /api/tenants/:tenantId/memory/documents/:documentId/purge` | `purge` | none | `200 { documentId, deleted, reason? }`; `403` unless caller is the document's creator; `404` unknown document. Hard-deletes the row — irreversible; refused while a `durable` version is untombstoned. | | `POST /api/tenants/:tenantId/memory/versions/:versionId/retention-class` | `forget` | `{ retention_class }` | `200 { versionId, documentId, status }`; `400` invalid class; `403` unless caller is the version's creator; `404` unknown version. | -`registerMemoryRoutes` and `createMemory({ app })` register these seven HTTP +`createMemoryRoutes` serves these seven HTTP routes (add, search, list, feed, forget, purge, retention-class). **Capture** (`services/capture.ts`) is the write path inside `add`. **Search** (`services/search.ts`) is hybrid retrieval. Deployed agents do @@ -658,7 +659,7 @@ have HTTP to the tenant tree can use `createMemoryHttpClient` ### Run-scoped sidecar (`mountWorkflowMemory`) `src/workflow-mount.ts`, re-exported from the barrel. Parallel to the -tenant tree — do not fold agent-bearer auth into `registerMemoryRoutes`. +tenant tree — do not fold agent-bearer auth into `createMemoryRoutes`. `package.json` exports `@corbits/memory/sidecar-bundle` → `src/sidecar-bundle.ts`. @@ -713,7 +714,7 @@ store implements `WritableGrantStore.putGrant`: 1. Appends `memory.doc:` to the document's `access_tags`. 2. Writes one allow/`search` grant per peer on that resource, origin `system`, with `conditions.memoryShare` audit payload. -3. `resolveGrantConfig` merges `MEMORY_SHARE_CONDITION_REGISTRY` so those +3. `createMemory` merges `MEMORY_SHARE_CONDITION_REGISTRY` so those condition keys are not fail-closed-skipped by `@intx/authz`. Without a writable store: tags only + warn log (peers need host grants). diff --git a/PRODUCT.md b/PRODUCT.md index 3ce929d..b3336b5 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -36,11 +36,11 @@ never creates one; it mounts onto yours. | Surface | Role | | --- | --- | -| `createMemory({ app, … })` | Register `/api/tenants/:tenantId/memory/*` + return the plane | +| `createMemory({ … })` | Build the plane | +| `createMemoryRoutes({ memory, requireGrant })` | Hono sub-app the host mounts at `/api/tenants/:tenantId/memory` | | `mountWorkflowMemory(app, { memory, agentToken })` | Parallel run-scoped `/api/workflow-memory/*` for deployed agents | | `loadMemoryConfig()` | Config from env | | `runMemoryMigrations(url)` | Apply pgvector schema | -| `registerMemoryRoutes` | Low-level HTTP only (optional) | | `@corbits/memory/sidecar-bundle` | Deployed-agent factory — no client code, no base URL, no token | | `@corbits/memory/distiller` | Optional process helpers: `runDistillTick`, `createResidentDistiller` | @@ -80,7 +80,8 @@ Deployed agent (sidecar-bundle) ┌──────────────────────────────────────────────┐ │ Host Interchange createApp │ │ principal + tenant on context │ -│ + createMemory({ app, grantStore, … }) │ +│ + createMemory({ grantStore, … }) │ +│ + app.route(…, createMemoryRoutes(deps)) │ │ grants: memory:add | memory:search │ │ documentStore: pgvector | host | fake │ │ + mountWorkflowMemory(app, { memory, … }) │ diff --git a/README.md b/README.md index 1ce4155..6fd10ab 100644 --- a/README.md +++ b/README.md @@ -24,29 +24,38 @@ yarn add @corbits/memory bun add @corbits/memory ``` -Write the mount as a function that takes your hub's `app`, `grantStore`, and -`conditionRegistry` — the same trio you already pass to +Build the plane, then mount its routes on your hub's `app` with the +`grantStore` and `conditionRegistry` you already pass to `createRequireGrant`/`createApp`: ```ts import { Hono } from "hono"; -import type { TenantEnv } from "@intx/hub-api"; +import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; import type { ConditionRegistry, GrantStore } from "@intx/authz"; -import { createMemory, loadMemoryConfig, type Memory } from "@corbits/memory"; +import { + createMemory, + createMemoryRoutes, + loadMemoryConfig, + type Memory, +} from "@corbits/memory"; export function installMemory( app: Hono, grantStore: GrantStore, conditionRegistry: ConditionRegistry, ): Memory { - const memoryApp = new Hono(); const memory = createMemory({ - app: memoryApp, config: loadMemoryConfig(), // DATABASE_URL + embed env — see below grantStore, conditionRegistry, }); - app.route("/", memoryApp); + app.route( + "/api/tenants/:tenantId/memory", + createMemoryRoutes({ + memory, + requireGrant: createRequireGrant({ grantStore, conditionRegistry }), + }), + ); return memory; } ``` @@ -125,10 +134,9 @@ at `@corbits/memory/sidecar-bundle`. ## How it works -`createMemory` builds the plane. Pass `app` to register -`/api/tenants/:tenantId/memory/*` behind `requireGrant("memory", …)` — -`grantStore` is required for that mount. `loadMemoryConfig` lives on the -barrel and at `@corbits/memory/config`. +`createMemory` builds the plane. `createMemoryRoutes` returns its HTTP routes +as a Hono sub-app, each guarded by `requireGrant("memory", …)`. +`loadMemoryConfig` lives on the barrel and at `@corbits/memory/config`. Capability grants (`memory:add` / `memory:search` / `memory:forget` / `memory:purge`) gate the routes. Per-document visibility is Interchange @@ -167,9 +175,8 @@ mail-triggered workflow is ticked by addressing it directly. No import of ### Lower-level: in-process calls, no HTTP -`app` is optional. Passing only `config` builds the plane without -registering routes, for a host worker that calls `add`/`search` directly -(the resident distiller does this): +A host worker can skip `createMemoryRoutes` and call `add`/`search` on the +plane directly (the resident distiller does this): ```ts import { createMemory, loadMemoryConfig } from "@corbits/memory"; @@ -203,8 +210,8 @@ bun run typecheck # tsc --noEmit bun run test # bun test ./src ``` -Tests use `createFakeDocumentStore`/`createFakeSourceProvider` (exported for -this purpose) so the suite runs without Postgres. `bun run build` compiles +Unit tests use the in-repo `createFakeDocumentStore`/`createFakeSourceProvider` +so the suite runs without Postgres. `bun run build` compiles `src/` to `dist/`, which is what the package publishes. ## License diff --git a/src/core/merge-local-live.ts b/src/core/merge-local-live.ts index afa23f6..a718bfe 100644 --- a/src/core/merge-local-live.ts +++ b/src/core/merge-local-live.ts @@ -1,5 +1,5 @@ /** - * MergeLocalLiveV1 — combine local DocumentStore hits with live SourceProvider + * Local/live merge — combine local DocumentStore hits with live SourceProvider * hits into one ranked list. * * Spec (frozen for M3/M4): diff --git a/src/index.ts b/src/index.ts index 20a90b7..42eb397 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,30 +1,14 @@ /** * @corbits/memory — add / search / list for Interchange hubs. * - * One entry: `createMemory(options)`. Pass `app` to register HTTP routes on - * a Hono host. Identity is `c.get("principal")` on HTTP, or `principalId` + - * `tenantId` in-process. Authz is the host grant store — this package - * authenticates nothing itself. + * `createMemory(options)` builds the plane; `createMemoryRoutes(deps)` + * returns its HTTP routes as a Hono sub-app for the host to mount. Identity + * is `c.get("principal")` on HTTP, or `principalId` + `tenantId` in-process. + * Authz is the host grant store — this package authenticates nothing itself. * - * Distiller is first-class: `createResidentDistiller` / `runDistillTick` from - * `@corbits/memory/distiller` (or re-exported below). Inference stays host- - * injected; the package ships the workflow + tick helpers so apps opt in - * with a few lines. + * The resident distiller ships at `@corbits/memory/distiller`, migrations at + * `@corbits/memory/migrations`. */ -import type { Hono } from "hono"; -import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; - -import { - createMemory as createMemoryPlane, - resolveGrantConfig, - type Memory, - type MemoryOptions, -} from "./memory.js"; -import { - registerMemoryRoutes, - type CallerResolver, - type RouteDeps, -} from "./routes/mount.js"; // Config export type { MemoryConfig } from "./mount-config.js"; @@ -32,8 +16,7 @@ export { loadMemoryConfig } from "./mount-config.js"; export type { EngineConfig } from "./config.js"; export { RerankConfigError } from "./core/rerank-client.js"; -// Memory plane — types from memory.ts; createMemory is defined below so it -// can optionally register HTTP routes when `app` is passed. +// Memory plane export type { HybridSearchResult, MemoryAddParams, @@ -56,14 +39,22 @@ export type { TimelineEvent, } from "./memory.js"; export { + createMemory, MemoryError, - resolveGrantConfig, SEARCH_LIMIT_MIN, SEARCH_LIMIT_MAX, LIST_LIMIT_MIN, LIST_LIMIT_MAX, } from "./memory.js"; +// HTTP routes +export { + createMemoryRoutes, + type CallerResolver, + type ResolvedCaller, + type RouteDeps, +} from "./routes/mount.js"; + // Installer discovery — grant *requirements* (not live grants) export { capabilityIdsForSurface, @@ -88,111 +79,7 @@ export type { LiveSearchItem, SourceProvider, } from "./ports/types.js"; - -export { - createFakeDocumentStore, - createFakeSourceProvider, -} from "./ports/fakes.js"; - export type { WritableGrantStore } from "./ports/writable-grant-store.js"; -export { - createInMemoryWritableGrantStore, - isWritableGrantStore, -} from "./ports/writable-grant-store.js"; - -// Share materialization (CL-5873) -export { - buildShareGrants, - documentTag, - materializeShareGrants, - MEMORY_SHARE_CONDITION_KEY, - MEMORY_SHARE_CONDITION_REGISTRY, - shareWidenReceipt, - splitAudienceWiden, - type MaterializeShareGrantsInput, - type MemoryShareCondition, - type ShareWidenReceipt, -} from "./services/share-grants.js"; - - -// Transform / replay surface (CL-5872) -export { - createTransformConfig, - demoteGeneration, - listTransformConfigs, - promoteGeneration, - resolveGenerationSearchParams, - runTransform, - TransformConfigNotFoundError, - TransformPromoteError, - type GenerationSearchParams, - type TransformConfigRow, - type TransformRunRow, -} from "./services/transform.js"; - -// Capture feed (CL-5868) -export { - fetchFeed, - FEED_LIMIT_DEFAULT, - FEED_LIMIT_MAX, - FEED_LIMIT_MIN, - type FeedArgs, - type FeedEntry, - type FeedResult, -} from "./services/feed.js"; - -// Retention / forgetting (CL-5871) -export { - deprecateVersion, - hardDeleteDocument, - setRetentionClass, - sweepEphemeral, - tombstoneDocument, - type RetentionMutationResult, -} from "./services/retention.js"; - -// Resident distiller (CL-5869) — also `@corbits/memory/distiller` -export { - RESIDENT_DISTILLER_AGENT_ID, - RESIDENT_DISTILLER_WORKFLOW_ID, - buildDistilledClaim, - createResidentDistiller, - resolveNextCursor, - runDistillTick, - shouldProcessFeedEntry, - type BuildDistilledClaimArgs, - type CreateResidentDistillerOpts, - type DistillOutcome, - type DistillTickFeedEntry, - type DistillTickPage, - type DistillTickResult, - type DistilledClaimWrite, - type FeedEntryLike, - type ResidentDistiller, - type RunDistillTickArgs, -} from "./distiller/index.js"; - -// Corroboration / living relevancy (CL-5867) -export { - corroborationFactor, - CORROBORATION_COUNT_LOG_CAP, - CORROBORATION_STRONG_FLOOR, - effectiveAuthority, - meetsStrongEvidenceGate, - type CorroborationCounts, - type StrongEvidenceSignals, -} from "./core/corroboration.js"; - -// Embed model registry (ensure vs activate) -export { - activateEmbedModel, - activateEmbedModelByKey, - clearActiveEmbedModels, - ensureEmbedModel, - resolveActiveEmbedTable, - resolveEmbedTableByModelKey, -} from "./core/embed-model-registry.js"; - // Run-scoped routes for deployed agents (bearer + run address, no session). // The tools that call them ship at `@corbits/memory/sidecar-bundle`. @@ -214,107 +101,3 @@ export { type MemoryHttpConfig, type MemorySearchBody, } from "./http-client.js"; - -// Migrations -export { runMemoryMigrations } from "./migrations.js"; - -// Degrade metrics — no metrics dependency exists in this package (see -// core/degrade-metrics.ts); a host with its own metrics backend polls this -// snapshot and forwards it, rather than the engine owning a /metrics port. -export { - getDegradeMetricsSnapshot, - getAllDegradeMetricsSnapshots, - configureDegradeMetrics, - type DegradeMetricsSnapshot, - type DegradeMetricsConfig, -} from "./core/degrade-metrics.js"; -export { - DEFAULT_FTS_LANGUAGE, - type FtsVerifySqlClient, - parseFtsLanguage, - verifyFtsLanguage, -} from "./core/fts-language.js"; - -// Granular HTTP composition (most hosts use createMemory({ app, … }) instead) -export { - registerMemoryRoutes, - type CallerResolver, - type GrantConfig, - type ResolvedCaller, -} from "./routes/mount.js"; - -export type CreateMemoryOptions = MemoryOptions & { - /** - * When set, register `/api/tenants/:tenantId/memory/*` on this Hono app. - * Requires `grantStore` (routes are guarded with `requireGrant("memory", …)`). - * On a real hub, mount under the same app that already runs - * `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; -}; - -/** - * Build a memory plane. Optionally register HTTP routes when `app` is set. - * - * @example In-process only - * ```ts - * const memory = createMemory({ - * documentStore: createFakeDocumentStore(), - * grantStore, - * conditionRegistry, - * }); - * await memory.add({ principalId, tenantId, content: { title, text } }); - * const { items } = await memory.search({ principalId, tenantId, query }); - * ``` - * - * @example HTTP on a Hono host - * ```ts - * createMemory({ - * app, - * documentStore: createFakeDocumentStore(), - * grantStore, - * conditionRegistry, - * }); - * // POST …/memory/add | search · GET …/memory/list - * // under /api/tenants/:tenantId/ - * ``` - */ -export function createMemory(options: CreateMemoryOptions): Memory { - const { app, callerResolver, grantStore, conditionRegistry, ...planeOpts } = - options; - const grants = resolveGrantConfig({ - ...(grantStore !== undefined ? { grantStore } : {}), - ...(conditionRegistry !== undefined ? { conditionRegistry } : {}), - }); - const memory = createMemoryPlane({ - ...planeOpts, - ...(grantStore !== undefined ? { grantStore } : {}), - ...(conditionRegistry !== undefined ? { conditionRegistry } : {}), - }); - - if (app) { - if (!grants) { - throw new Error( - "createMemory({ app }): grantStore is required when registering HTTP routes " + - "(pass grantStore; conditionRegistry is optional)", - ); - } - const requireGrant = createRequireGrant(grants); - const deps: RouteDeps = { - memory, - requireGrant, - grants, - ...(callerResolver !== undefined ? { callerResolver } : {}), - }; - registerMemoryRoutes(app, deps); - } - - return memory; -} diff --git a/src/memory.ts b/src/memory.ts index 7f03611..47eecd8 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -417,24 +417,25 @@ export type MemoryOptions = { */ documentStore?: DocumentStore; /** - * Live source connectors (tools-shaped). Merged into search via - * MergeLocalLiveV1; not a DocumentStore replacement. + * Live source connectors (tools-shaped). Merged into search results + * alongside local hits; not a DocumentStore replacement. */ sources?: SourceProvider[]; }; -/** Bundle host authz pieces for route guards and document access. */ -export function resolveGrantConfig( - options: Pick, +/** Bundle host authz pieces for document access. */ +function resolveGrantConfig( + grantStore: GrantConfig["grantStore"] | undefined, + conditionRegistry: GrantConfig["conditionRegistry"] | undefined, ): GrantConfig | undefined { - if (!options.grantStore) return undefined; + if (!grantStore) return undefined; // Merge memoryShare evaluator under host keys so share grants with // conditions are not fail-closed-skipped by @intx/authz. return { - grantStore: options.grantStore, + grantStore, conditionRegistry: { ...MEMORY_SHARE_CONDITION_REGISTRY, - ...(options.conditionRegistry ?? {}), + ...conditionRegistry, }, }; } @@ -525,8 +526,8 @@ function resolveAddAccessTags(params: MemoryAddParams): string[] { * - Pass `options.grantStore` for document access filtering on search/list. * `conditionRegistry` is optional (defaults to `{}`). * - Rerank config is validated at construction when using the default store. - * - Pass `options.sources` for live SourceProviders; search merges via - * MergeLocalLiveV1 (fail-soft, 800ms timeout, prefer-local dedupe). + * - Pass `options.sources` for live SourceProviders; search merges them with + * local hits (fail-soft, 800ms timeout, prefer-local dedupe). * - Document access uses grant tags via the host GrantStore (not mini-ACL). * - Inference is host-owned and ephemeral: call your model, then `add` / * `search`. Core does not run an ingest agent or bake LLM into writes. @@ -557,10 +558,7 @@ export function createMemory(options: MemoryOptions = {}): Memory { textExtractor, sources, } = options; - const grants = resolveGrantConfig({ - ...(grantStore !== undefined ? { grantStore } : {}), - ...(conditionRegistry !== undefined ? { conditionRegistry } : {}), - }); + const grants = resolveGrantConfig(grantStore, conditionRegistry); let transformDeps: EngineTransformDeps | undefined; const store = @@ -580,10 +578,7 @@ export function createMemory(options: MemoryOptions = {}): Memory { return createPlaneFromStore( store, grants, - { - ...(textExtractor ? { textExtractor } : {}), - ...(sources ? { sources } : {}), - }, + { textExtractor, sources }, transformDeps, ); } @@ -754,7 +749,10 @@ function mergeToSearchResult(params: { function createPlaneFromStore( store: DocumentStore, grants: GrantConfig | undefined, - options: MemoryOptions, + options: { + textExtractor: TextExtractor | undefined; + sources: SourceProvider[] | undefined; + }, transformDeps?: EngineTransformDeps, ): Memory { async function searchMerged( diff --git a/src/ports/memory-plane.test.ts b/src/ports/memory-plane.test.ts index 7feb0ea..734f5a3 100644 --- a/src/ports/memory-plane.test.ts +++ b/src/ports/memory-plane.test.ts @@ -8,10 +8,8 @@ import { type GrantRule, } from "@intx/authz"; -import { - createFakeDocumentStore, - createMemory, -} from "../index.js"; +import { createMemory } from "../memory.js"; +import { createFakeDocumentStore } from "./fakes.js"; const TENANT = "t_mem"; const PRINCIPAL = "p_mem"; diff --git a/src/ports/merge-plane.test.ts b/src/ports/merge-plane.test.ts index 82b245d..27dcbc9 100644 --- a/src/ports/merge-plane.test.ts +++ b/src/ports/merge-plane.test.ts @@ -3,11 +3,8 @@ */ import { describe, expect, it } from "bun:test"; -import { - createFakeDocumentStore, - createFakeSourceProvider, - createMemory, -} from "../index.js"; +import { createMemory } from "../memory.js"; +import { createFakeDocumentStore, createFakeSourceProvider } from "./fakes.js"; import type { LiveSearchItem } from "./types.js"; const TENANT = "t_merge"; diff --git a/src/ports/mount-fakes.test.ts b/src/ports/mount-fakes.test.ts index def0c96..4a7a34e 100644 --- a/src/ports/mount-fakes.test.ts +++ b/src/ports/mount-fakes.test.ts @@ -4,17 +4,15 @@ */ import { describe, expect, it } from "bun:test"; import { Hono } from "hono"; -import type { TenantEnv } from "@intx/hub-api"; +import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; import { createInMemoryGrantStore, type GrantRule, } from "@intx/authz"; -import { - createFakeDocumentStore, - createFakeSourceProvider, - createMemory, -} from "../index.js"; +import { createMemory } from "../memory.js"; +import { createMemoryRoutes } from "../routes/mount.js"; +import { createFakeDocumentStore, createFakeSourceProvider } from "./fakes.js"; const TENANT = "tenant_fake"; const PRINCIPAL = "principal_fake"; @@ -85,16 +83,23 @@ describe("createMemory with fakes only", () => { ]), ]; const app = appWithPrincipal(); - const memory = createMemory({ - app, - grantStore: createInMemoryGrantStore([ - grant("add"), - grant("search"), - ]), + const grantConfig = { + grantStore: createInMemoryGrantStore([grant("add"), grant("search")]), conditionRegistry: {}, + }; + const memory = createMemory({ + grantStore: grantConfig.grantStore, + conditionRegistry: grantConfig.conditionRegistry, documentStore: store, sources, }); + app.route( + "/api/tenants/:tenantId/memory", + createMemoryRoutes({ + memory, + requireGrant: createRequireGrant(grantConfig), + }), + ); const { documentId } = await memory.add({ tenantId: TENANT, diff --git a/src/routes/add.ts b/src/routes/add.ts index fdc5a32..e69f844 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -26,7 +26,7 @@ const AddResponse = type({ export function mountAddRoute(app: Hono, deps: RouteDeps): void { app.post( - "/api/tenants/:tenantId/memory/add", + "/add", describeRoute({ tags: ["memory"], diff --git a/src/routes/deps.test.ts b/src/routes/deps.test.ts index 752e7ce..d9d181c 100644 --- a/src/routes/deps.test.ts +++ b/src/routes/deps.test.ts @@ -29,22 +29,14 @@ function grant(principalId: string, action: string): GrantRule { const noopRequireGrant: RequireGrant = () => (async () => {}) as never; -// Minimal RouteDeps for unit tests — routes here only touch grants/requireGrant. -function deps(grants: RouteDeps["grants"], requireGrant = noopRequireGrant): RouteDeps { +// Minimal RouteDeps for unit tests — routes here only touch requireGrant. +function deps(requireGrant = noopRequireGrant): RouteDeps { return { memory: {} as RouteDeps["memory"], - grants, requireGrant, }; } -function grantsWith(...rules: GrantRule[]): RouteDeps["grants"] { - return { - grantStore: createInMemoryGrantStore(rules), - conditionRegistry: {}, - }; -} - function ctxWithPrincipal( principal: { id: string; tenantId: string } | undefined, ): Context { @@ -113,7 +105,7 @@ describe("grantGuard", () => { called = { resource: String(resource), action }; return (async () => {}) as never; }; -grantGuard(deps(grantsWith(), requireGrant), "add"); +grantGuard(deps(requireGrant), "add"); expect(called).toEqual({ resource: "memory", action: "add" }); }); }); @@ -142,7 +134,7 @@ describe("resolveCaller", () => { 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 () => { + await resolveCaller(deps())(ctx, async () => { nextCalled = true; }); expect(nextCalled).toBe(true); @@ -158,7 +150,7 @@ describe("resolveCaller", () => { principalId: "run-principal", }; const routeDeps: RouteDeps = { - ...deps(grantsWith()), + ...deps(), callerResolver: () => resolved, }; let nextCalled = false; @@ -181,7 +173,7 @@ describe("resolveCaller", () => { test("responds 401 and never calls next() when the resolver rejects the request", async () => { const { ctx, sets, jsonCalls } = fakeContext(); const routeDeps: RouteDeps = { - ...deps(grantsWith()), + ...deps(), callerResolver: () => null, }; let nextCalled = false; @@ -200,7 +192,7 @@ describe("resolveCaller", () => { test("supports an async callerResolver", async () => { const { ctx, sets } = fakeContext(); const routeDeps: RouteDeps = { - ...deps(grantsWith()), + ...deps(), callerResolver: async () => ({ tenantId: "tenant-async", principalId: "principal-async", @@ -225,7 +217,7 @@ describe("resolveCaller", () => { async (_label, resolved) => { const { ctx, sets, jsonCalls } = fakeContext(); const routeDeps: RouteDeps = { - ...deps(grantsWith()), + ...deps(), callerResolver: () => resolved as ResolvedCaller, }; let nextCalled = false; diff --git a/src/routes/deps.ts b/src/routes/deps.ts index 687f6f2..a009014 100644 --- a/src/routes/deps.ts +++ b/src/routes/deps.ts @@ -59,8 +59,6 @@ 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 @@ -98,7 +96,7 @@ export function caller(c: Context): { * rather than as the host missing middleware. `caller()` below has a perfectly * good error message for exactly this case, but it never gets to run. * - * Routes mount at `/api/tenants/:tenantId/memory/*` so a real hub's + * Hosts mount the routes at `/api/tenants/:tenantId/memory` so a real hub's * `createResolveTenant` already sets principal + tenant. This guard is a * fail-closed safety net for mis-mounted hosts and unit tests. */ diff --git a/src/routes/feed.ts b/src/routes/feed.ts index 358b37c..9ebaf17 100644 --- a/src/routes/feed.ts +++ b/src/routes/feed.ts @@ -34,7 +34,7 @@ const FeedResponse = type({ export function mountFeedRoute(app: Hono, deps: RouteDeps): void { app.get( - "/api/tenants/:tenantId/memory/feed", + "/feed", describeRoute({ tags: ["memory"], diff --git a/src/routes/list.ts b/src/routes/list.ts index 4dbe7cd..3188803 100644 --- a/src/routes/list.ts +++ b/src/routes/list.ts @@ -30,7 +30,7 @@ const ListResponse = type({ export function mountListRoute(app: Hono, deps: RouteDeps): void { app.get( - "/api/tenants/:tenantId/memory/list", + "/list", describeRoute({ tags: ["memory"], diff --git a/src/routes/mount.ts b/src/routes/mount.ts index 095d4d8..5d7ed49 100644 --- a/src/routes/mount.ts +++ b/src/routes/mount.ts @@ -1,16 +1,22 @@ /** - * 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.) + * Memory HTTP routes as a typed Hono sub-app. The host mounts it under its + * tenant tree, below the middleware that sets `principal`/`tenant`: * - * 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 + * ```ts + * app.route( + * "/api/tenants/:tenantId/memory", + * createMemoryRoutes({ memory, requireGrant }), + * ); + * ``` + * + * The `:tenantId` in that prefix is never read by any handler — it exists + * only so the routes share 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 { Hono } from "hono"; import type { TenantEnv } from "@intx/hub-api"; import type { RouteDeps } from "./deps.js"; @@ -24,18 +30,11 @@ import { mountSetRetentionClassRoute, } from "./retention.js"; -export type { - CallerResolver, - GrantConfig, - ResolvedCaller, - RouteDeps, -} from "./deps.js"; +export type { CallerResolver, ResolvedCaller, RouteDeps } from "./deps.js"; /** HTTP JSON routes: add, search, list, feed, forget, purge, retention-class. */ -export function registerMemoryRoutes( - app: Hono, - deps: RouteDeps, -): void { +export function createMemoryRoutes(deps: RouteDeps): Hono { + const app = new Hono(); mountAddRoute(app, deps); mountSearchRoute(app, deps); mountListRoute(app, deps); @@ -43,4 +42,5 @@ export function registerMemoryRoutes( mountForgetRoute(app, deps); mountPurgeRoute(app, deps); mountSetRetentionClassRoute(app, deps); + return app; } diff --git a/src/routes/retention.ts b/src/routes/retention.ts index d5fcdef..2d7602d 100644 --- a/src/routes/retention.ts +++ b/src/routes/retention.ts @@ -59,7 +59,7 @@ const RetentionClassResponse = type({ export function mountForgetRoute(app: Hono, deps: RouteDeps): void { app.post( - "/api/tenants/:tenantId/memory/documents/:documentId/forget", + "/documents/:documentId/forget", describeRoute({ tags: ["memory"], @@ -108,7 +108,7 @@ export function mountForgetRoute(app: Hono, deps: RouteDeps): void { export function mountPurgeRoute(app: Hono, deps: RouteDeps): void { app.post( - "/api/tenants/:tenantId/memory/documents/:documentId/purge", + "/documents/:documentId/purge", describeRoute({ tags: ["memory"], @@ -161,7 +161,7 @@ export function mountSetRetentionClassRoute( deps: RouteDeps, ): void { app.post( - "/api/tenants/:tenantId/memory/versions/:versionId/retention-class", + "/versions/:versionId/retention-class", describeRoute({ tags: ["memory"], diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index 763188a..fd3dc16 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -6,7 +6,7 @@ import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; import type { Memory, TimelineEvent } from "../memory.js"; import { MemoryError } from "../memory.js"; -import { registerMemoryRoutes } from "./mount.js"; +import { createMemoryRoutes } from "./mount.js"; import type { RouteDeps } from "./deps.js"; function grant(principalId: string, action: string): GrantRule { @@ -145,7 +145,6 @@ const sharedBrowser = (() => { const session = { principalId: PRINCIPAL }; const deps: RouteDeps = { memory: rec.plane, - grants: grantConfig, requireGrant: createRequireGrant(grantConfig), }; const app = new Hono(); @@ -171,7 +170,7 @@ const sharedBrowser = (() => { }); await next(); }); - registerMemoryRoutes(app, deps); + app.route("/api/tenants/:tenantId/memory", createMemoryRoutes(deps)); return { app, grantList, catalog, session, rec }; })(); @@ -313,14 +312,13 @@ const sharedMachine = (() => { } = { fn: () => null }; const deps: RouteDeps = { memory: rec.plane, - grants: grantConfig, requireGrant: createRequireGrant(grantConfig), callerResolver: (c) => resolverBox.fn(c), }; // 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); + app.route("/api/tenants/:tenantId/memory", createMemoryRoutes(deps)); return { app, grantList, catalog, resolverBox, rec }; })(); @@ -365,11 +363,10 @@ function buildAppWithoutPrincipal() { }; const deps: RouteDeps = { memory: plane, - grants: grantConfig, requireGrant: createRequireGrant(grantConfig), }; const app = new Hono(); - registerMemoryRoutes(app, deps); + app.route("/api/tenants/:tenantId/memory", createMemoryRoutes(deps)); return app; } @@ -1096,7 +1093,6 @@ describe("memory HTTP routes — resolver trust-boundary and row-fabrication con }; const deps: RouteDeps = { memory: plane, - grants: grantConfig, requireGrant: createRequireGrant(grantConfig), }; const app = new Hono(); @@ -1109,7 +1105,7 @@ describe("memory HTTP routes — resolver trust-boundary and row-fabrication con c.set("tenant", canaryTenant); await next(); }); - registerMemoryRoutes(app, deps); + app.route("/api/tenants/:tenantId/memory", createMemoryRoutes(deps)); const res = await app.request( "/api/tenants/t1/memory/add", diff --git a/src/routes/search.ts b/src/routes/search.ts index 30814dd..45b230e 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -39,7 +39,7 @@ const SearchResponse = type({ export function mountSearchRoute(app: Hono, deps: RouteDeps): void { app.post( - "/api/tenants/:tenantId/memory/search", + "/search", describeRoute({ tags: ["memory"], diff --git a/src/services/share-grants.ts b/src/services/share-grants.ts index c187324..434c795 100644 --- a/src/services/share-grants.ts +++ b/src/services/share-grants.ts @@ -9,7 +9,7 @@ * Origin is constrained by `@intx/types` to system|role|creator|invoker — * memory-share provenance lives in `conditions.memoryShare` (audit payload). * Authz skips grants with non-null conditions unless a registry is provided; - * use `MEMORY_SHARE_CONDITION_REGISTRY` (merged automatically in resolveGrantConfig). + * use `MEMORY_SHARE_CONDITION_REGISTRY` (createMemory merges it automatically). */ import type { ConditionRegistry, GrantRule } from "@intx/authz"; import { newId } from "../core/id.js"; @@ -36,7 +36,7 @@ export type MemoryShareCondition = { /** * Default registry so share grants with conditions are not fail-closed-skipped. - * Hosts may override the key; resolveGrantConfig merges host keys on top. + * Hosts may override the key; createMemory merges host keys on top. */ export const MEMORY_SHARE_CONDITION_REGISTRY: ConditionRegistry = { [MEMORY_SHARE_CONDITION_KEY]: () => true,