From c108d1687b79969a90582590551a579023ba34f6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 23:12:27 -0700 Subject: [PATCH] feat(routes)!: replace createMemory({ app }) with createMemoryRoutes createMemoryRoutes(deps) builds the routes on a fresh Hono with paths relative to the mount point; the host mounts it at /api/tenants/:tenantId/memory with its own requireGrant. The barrel's createMemory wrapper that mutated a passed app is gone, so the one createMemory is the plane factory in memory.ts, and it assembles its options as explicit literals rather than conditional spreads. The package root now exports only the public API: internal services, helpers and test fakes are gone from it, the distiller and migrations stay on their own subpaths, and the internal MergeLocalLiveV1 name no longer appears in shipped declarations. --- AGENTS.md | 2 +- ARCHITECTURE.md | 13 +- CHANGELOG.md | 12 +- IMPLEMENTATION.md | 17 +-- PRODUCT.md | 7 +- README.md | 39 +++--- src/core/merge-local-live.ts | 2 +- src/index.ts | 249 +++------------------------------ src/memory.ts | 36 +++-- src/ports/memory-plane.test.ts | 6 +- src/ports/merge-plane.test.ts | 7 +- src/ports/mount-fakes.test.ts | 29 ++-- src/routes/add.ts | 2 +- src/routes/deps.test.ts | 24 ++-- src/routes/deps.ts | 4 +- src/routes/feed.ts | 2 +- src/routes/list.ts | 2 +- src/routes/mount.ts | 36 ++--- src/routes/retention.ts | 6 +- src/routes/routes.test.ts | 14 +- src/routes/search.ts | 2 +- src/services/share-grants.ts | 4 +- 22 files changed, 151 insertions(+), 364 deletions(-) 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,