From b7bc53b54f70342ce5277ef2beea0f5faebe92e6 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 18:09:24 +0000 Subject: [PATCH 01/13] perf(web): run the /lore list, plan-usage and invites reads in parallel The three reads were Next.js server actions, which the app router runs one at a time per client and which cannot take an AbortSignal. On /lore they queued (plan usage -> pending invites -> memory list) on every load and every window focus, so the TopBar activity bar stayed on long after the visible panels had loaded. - useMemories calls POST /memories/list from the browser with React Query's signal (body building moved unchanged into the pure lib/memory-list.ts) - useMemoryTotal and usePendingInvitesForMe read new GET route handlers (/api/plan/usage, /api/invites/pending) that wrap the existing server functions Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../web/src/app/api/invites/pending/route.ts | 25 +++ packages/web/src/app/api/plan/usage/route.ts | 23 +++ packages/web/src/lib/app-route.ts | 40 +++++ packages/web/src/lib/lore.ts | 168 ++---------------- packages/web/src/lib/memory-list.ts | 154 ++++++++++++++++ packages/web/src/lib/queries/lore.ts | 38 +++- .../web/src/lib/queries/pending-invites.ts | 15 +- packages/web/src/lib/queries/plan.ts | 14 +- 8 files changed, 312 insertions(+), 165 deletions(-) create mode 100644 packages/web/src/app/api/invites/pending/route.ts create mode 100644 packages/web/src/app/api/plan/usage/route.ts create mode 100644 packages/web/src/lib/app-route.ts create mode 100644 packages/web/src/lib/memory-list.ts diff --git a/packages/web/src/app/api/invites/pending/route.ts b/packages/web/src/app/api/invites/pending/route.ts new file mode 100644 index 000000000..3d325428c --- /dev/null +++ b/packages/web/src/app/api/invites/pending/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; +import { listPendingInvitesForMe } from '@/lib/org-invites'; + +/** + * GET /api/invites/pending + * + * Pending org invites addressed to the caller — `listPendingInvitesForMe` over + * HTTP, for the shared `['pending-invites']` query behind the top bar's invites + * badge, the `/lore` banner and the Organization nav badge. Those used to call + * the server action directly. Next's router runs server actions one at a time + * per client, so on `/lore` this read sat in a queue with the plan-usage read + * and the Explorer's list on every load and every window focus. As a route + * handler it is an ordinary request that runs in parallel and the browser can + * abort. + * + * Same contract as the action: `[]` when there is no session or the query + * fails. Never cached: accepting or declining elsewhere must show up on the + * next refetch. + */ +export const dynamic = 'force-dynamic'; + +export async function GET() { + const invites = await listPendingInvitesForMe(); + return NextResponse.json(invites, { headers: { 'Cache-Control': 'no-store' } }); +} diff --git a/packages/web/src/app/api/plan/usage/route.ts b/packages/web/src/app/api/plan/usage/route.ts new file mode 100644 index 000000000..21602c693 --- /dev/null +++ b/packages/web/src/app/api/plan/usage/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { getPlanUsage } from '@/lib/plan'; + +/** + * GET /api/plan/usage + * + * The caller's plan and active memory count — `getPlanUsage` over HTTP, for the + * top bar's memory badge (`useMemoryTotal`). The badge used to call the server + * action directly. Next's router runs server actions one at a time per client, + * so on `/lore` it led a queue that the pending-invites read and the Explorer's + * list waited behind on every load and every window focus. As a route handler + * it is an ordinary request that runs in parallel and the browser can abort. + * + * Same contract as the action: `null` when there is no session or the RPC + * fails, and the badge degrades to 0. Never cached, because the count moves + * whenever an agent writes. + */ +export const dynamic = 'force-dynamic'; + +export async function GET() { + const usage = await getPlanUsage(); + return NextResponse.json(usage, { headers: { 'Cache-Control': 'no-store' } }); +} diff --git a/packages/web/src/lib/app-route.ts b/packages/web/src/lib/app-route.ts new file mode 100644 index 000000000..dae9df9ff --- /dev/null +++ b/packages/web/src/lib/app-route.ts @@ -0,0 +1,40 @@ +/** + * Browser GET of one of the dashboard's own Next route handlers (`app/api/**`), + * read as JSON. + * + * This exists for the reads that still need server-side code: there is no REST + * endpoint for them yet (`packages/web/CLAUDE.md` lists invites and plan usage + * as migration backlog). They used to be called as server actions straight from + * React Query, and Next's app router runs server actions one at a time per + * client. Every read queued behind whichever action was already in flight, and + * none of them could be aborted, so the header's activity bar stayed lit until + * the whole chain had drained. A route handler is an ordinary request: it runs + * alongside the others and takes React Query's AbortSignal. + * + * Same-origin, so the session cookie authenticates it; no token is passed. + * `no-store` because these answer "what is true now": a refetch on window focus + * must not be served from the HTTP cache. + */ + +/** A non-2xx answer from an app route — the query's error, so React Query can retry it. */ +export class AppRouteError extends Error { + constructor( + readonly path: string, + readonly status: number, + ) { + super(`GET ${path} failed with HTTP ${status}`); + this.name = 'AppRouteError'; + } +} + +export async function getAppRoute(path: string, signal?: AbortSignal): Promise { + const response = await fetch(path, { + method: 'GET', + credentials: 'same-origin', + cache: 'no-store', + headers: { Accept: 'application/json' }, + ...(signal ? { signal } : {}), + }); + if (!response.ok) throw new AppRouteError(path, response.status); + return (await response.json()) as T; +} diff --git a/packages/web/src/lib/lore.ts b/packages/web/src/lib/lore.ts index 421628c94..d1de4d813 100644 --- a/packages/web/src/lib/lore.ts +++ b/packages/web/src/lib/lore.ts @@ -2,7 +2,13 @@ /** * Server actions for memory (lore) management. - * Update, archive, restore, purge, and paginated list — all user-scoped. + * Update, archive, restore and purge — all user-scoped MUTATIONS. + * + * Reads do not belong here. Next's app router runs server actions one at a + * time per client, so a read served by one queues behind every other action in + * flight and cannot be aborted. The paginated list used to live in this file as + * `listMemories`; it is now a browser request (`fetchMemoryPage` in + * `queries/lore.ts`, body built by `lib/memory-list.ts`). * * Every one of these goes through LoreKit's own REST API (the `memories` edge * function) rather than querying PostgREST directly. They used to do the @@ -12,31 +18,18 @@ * contract drift, and these had. The dashboard is now a client of the same * documented surface the CLI and every agent use. * - * They stay SERVER actions (rather than moving into the client hooks) so the - * Explorer's data path is unchanged from the components' point of view, and so - * the access token is read from the cookie session rather than handed to the - * browser bundle. + * The mutations stay SERVER actions, reading the access token from the cookie + * session. A mutation is a single user gesture, so the one-at-a-time queue costs + * nothing here. The read hooks in `queries/lore.ts` use the browser session's + * token (`session-browser.ts`), which is the same user JWT. */ import { revalidatePath } from 'next/cache'; -import type { Page } from '@/lib/pagination/keyset'; -import { clampPageSize } from '@/lib/pagination/keyset'; -import { dateRangeBounds, type DateRangeInput } from '@/lib/pagination/filters'; -import type { LessonEntry } from '@/components/lore/LessonCard'; -import { normalizeTags } from '@/lib/tag-filter'; -import { filtersToBody, normalizeFilters, type Filter } from '@/lib/filters'; -import { DEFAULT_EXPLORER_SORT, type ExplorerSort } from '@/lib/explorer-sort'; -import { - retentionConditionsToListBody, - type RetentionConditions, -} from '@/lib/retention-filter'; -import { lessonFromMemoryEntry } from '@/lib/lesson-entry'; import { serverAccessToken } from '@/lib/api/session-server'; import { RestApiError } from '@/lib/api/rest'; import { archiveMemoryRequest, listMemoriesRequest, - listMemoriesPostRequest, purgeMemoriesRequest, restoreMemoryRequest, updateMemoryRequest, @@ -160,142 +153,3 @@ export async function purgeArchivedLessons( return { purged: 0, error: messageFor(err) }; } } - -// --------------------------------------------------------------------------- -// Paginated memory listing -// --------------------------------------------------------------------------- - -export interface MemoryFilters { - /** Filter to a single scope. Omit or pass null to return all scopes. */ - scope?: string | null; - /** Case-insensitive substring match against `key` or `value`. */ - search?: string; - /** Inclusive `from` / exclusive-day-end `to` interval on `created_at`. */ - range?: DateRangeInput | null; - /** - * Labels (`memories.tags`) a row must carry — ALL of them, not any. - * Absent or empty means "no label filter". - * - * @deprecated Superseded by {@link MemoryFilters.filters}. Folded into it - * below (as a `label` filter with the `all` operator) so there is one - * translation to the wire. - */ - tags?: string[]; - /** - * The Explorer's filter bar: one condition per dimension (label / agent / - * trigger / repo / branch / pull request), OR within a dimension and AND - * across them. Translated by the pure `filtersToBody`, which is the - * single place the UI vocabulary meets the wire vocabulary. - */ - filters?: Filter[]; - /** - * The retention-preview trio (`lib/retention-filter.ts`) — the same three - * conditions a saved retention policy matches on, narrowing the list to - * what that policy would catch. Absent/empty means no narrowing. - */ - retentionConditions?: RetentionConditions; - /** - * The list order (`lib/explorer-sort.ts`) — `created_at` (Newest, the - * default) or `opened_count` (Most chosen, migration 00113). A cursor is only - * valid for the order that minted it; the route restarts at page one if the - * two disagree, and the Explorer's query key changes with the sort anyway. - */ - sort?: ExplorerSort; - /** Page size, default 50, hard max 100. */ - pageSize?: number; - /** Opaque keyset cursor from a previous page's `nextCursor`. */ - cursor?: string | null; - /** - * When true, returns only archived memories (archived_at IS NOT NULL). - * When false/absent, returns only active memories (archived_at IS NULL). - */ - showArchived?: boolean; - /** - * Narrow to memories whose TTL runs out within N days — the route's - * `expiring_within_days` (1–365). Absent means no expiry narrowing. - * - * Passed straight through and bounded by the route, not here: an - * out-of-range value is a 400 the caller should see, and re-implementing the - * bound in the dashboard would be a second copy of it to keep in step. - */ - expiringWithinDays?: number; -} - -/** - * `total` — exact count of every memory the request's filters matched, - * ignoring pagination (`MemoryPageResponseSchema`'s optional field, - * `lorekit_memory_list`'s `total_count` column, migration 00094). `Page` - * itself stays total-less: it is shared with `useAuditLog`, which has no - * equivalent aggregate, so this is `MemoryPage`'s own addition rather than a - * field every keyset page now carries. - */ -export type MemoryPage = Page & { total?: number }; - -const DEFAULT_PAGE_SIZE = 50; -const MAX_PAGE_SIZE = 100; -const EMPTY_PAGE: MemoryPage = { rows: [], nextCursor: null, hasMore: false }; - -/** - * List a keyset page of the memories the caller can see, newest first, with - * optional combinable filters (scope / substring / date interval / labels). - * - * Ordering defaults to `created_at desc` — `sort: 'created_at'` — not the - * route's `updated_at` default: a memory migrated with a backdated `created_at` - * belongs at its original position in the Explorer, which is the order the list - * has always been in. `filters.sort` swaps in `opened_count` ("Most chosen"). - * - * Sent over `POST /memories/list` rather than `GET /memories`: the filter bar's - * dimensions are unbounded, and the query transport caps each one at 2048 - * characters — the ceiling that made the Explorer stop loading past ~50-75 - * selected values in a dimension. The BODY carries the filters; the ordering, - * the page size and the cursor are unchanged. - * - * Fails closed to an empty page on auth failure or API error — read-only, so - * failing closed is safe. - */ -export async function listMemories(filters: MemoryFilters = {}): Promise { - const token = await serverAccessToken(); - if (!token) return EMPTY_PAGE; - - const pageSize = clampPageSize(filters.pageSize, { def: DEFAULT_PAGE_SIZE, max: MAX_PAGE_SIZE }); - const bounds = dateRangeBounds(filters.range); - - // The deprecated `tags` shorthand becomes a `label` filter with the `all` - // operator — exactly what it has always meant — unless the caller already - // supplied one, in which case the explicit bar wins. - const legacyTags = normalizeTags(filters.tags); - const explicit = normalizeFilters(filters.filters ?? []); - const bar = - legacyTags.length === 0 || explicit.some((f) => f.field === 'label') - ? explicit - : normalizeFilters([...explicit, { field: 'label', operator: 'all', values: legacyTags }]); - - try { - const page = await listMemoriesPostRequest(token, { - limit: pageSize, - sort: filters.sort ?? DEFAULT_EXPLORER_SORT, - archived: filters.showArchived ?? false, - ...(filters.expiringWithinDays !== undefined - ? { expiring_within_days: filters.expiringWithinDays } - : {}), - ...(filters.scope ? { scope: filters.scope } : {}), - ...(filters.search ? { q: filters.search } : {}), - ...(bounds.gte ? { created_since: bounds.gte } : {}), - ...(bounds.lt ? { created_until: bounds.lt } : {}), - // OR within a dimension, AND across dimensions — see `filtersToBody`. - ...filtersToBody(bar), - ...retentionConditionsToListBody(filters.retentionConditions ?? {}), - ...(filters.cursor ? { cursor: filters.cursor } : {}), - }); - - return { - rows: page.entries.map(lessonFromMemoryEntry), - nextCursor: page.nextCursor, - hasMore: page.hasMore, - total: page.total, - }; - } catch (err) { - console.error('[listMemories] REST error:', messageFor(err)); - return EMPTY_PAGE; - } -} diff --git a/packages/web/src/lib/memory-list.ts b/packages/web/src/lib/memory-list.ts new file mode 100644 index 000000000..d7c21561a --- /dev/null +++ b/packages/web/src/lib/memory-list.ts @@ -0,0 +1,154 @@ +/** + * The Explorer's memory-list request: the filter state the UI holds, translated + * into the `POST /memories/list` body, and the response translated back into a + * page of `LessonEntry` rows. + * + * Pure on purpose, and in its own module rather than in `lib/lore.ts`: the list + * used to be the `listMemories` SERVER ACTION, and Next's app router runs server + * actions one at a time per client. On `/lore` it queued behind the two other + * server actions the top bar fired on every load and every window focus (plan + * usage, pending invites), so the list arrived last and the header's activity + * bar stayed on long after every visible panel had loaded. The list is now a + * plain browser request (`fetchMemoryPage` in `queries/lore.ts`) that runs in + * parallel and honours React Query's AbortSignal; this module is the part of + * the old action that was never about transport, so it moved here unchanged and + * gained a spec. + */ + +import type { ListMemoriesBody, MemoryPageResponse } from '@lorekit/schemas/memory'; +import type { LessonEntry } from '@/components/lore/LessonCard'; +import type { Page } from '@/lib/pagination/keyset'; +import { clampPageSize } from '@/lib/pagination/keyset'; +import { dateRangeBounds, type DateRangeInput } from '@/lib/pagination/filters'; +import { normalizeTags } from '@/lib/tag-filter'; +import { filtersToBody, normalizeFilters, type Filter } from '@/lib/filters'; +import { DEFAULT_EXPLORER_SORT, type ExplorerSort } from '@/lib/explorer-sort'; +import { + retentionConditionsToListBody, + type RetentionConditions, +} from '@/lib/retention-filter'; +import { lessonFromMemoryEntry } from '@/lib/lesson-entry'; + +export interface MemoryFilters { + /** Filter to a single scope. Omit or pass null to return all scopes. */ + scope?: string | null; + /** Case-insensitive substring match against `key` or `value`. */ + search?: string; + /** Inclusive `from` / exclusive-day-end `to` interval on `created_at`. */ + range?: DateRangeInput | null; + /** + * Labels (`memories.tags`) a row must carry — ALL of them, not any. + * Absent or empty means "no label filter". + * + * @deprecated Superseded by {@link MemoryFilters.filters}. Folded into it + * below (as a `label` filter with the `all` operator) so there is one + * translation to the wire. + */ + tags?: string[]; + /** + * The Explorer's filter bar: one condition per dimension (label / agent / + * trigger / repo / branch / pull request), OR within a dimension and AND + * across them. Translated by the pure `filtersToBody`, which is the + * single place the UI vocabulary meets the wire vocabulary. + */ + filters?: Filter[]; + /** + * The retention-preview trio (`lib/retention-filter.ts`) — the same three + * conditions a saved retention policy matches on, narrowing the list to + * what that policy would catch. Absent/empty means no narrowing. + */ + retentionConditions?: RetentionConditions; + /** + * The list order (`lib/explorer-sort.ts`) — `created_at` (Newest, the + * default) or `opened_count` (Most chosen, migration 00113). A cursor is only + * valid for the order that minted it; the route restarts at page one if the + * two disagree, and the Explorer's query key changes with the sort anyway. + */ + sort?: ExplorerSort; + /** Page size, default 50, hard max 100. */ + pageSize?: number; + /** Opaque keyset cursor from a previous page's `nextCursor`. */ + cursor?: string | null; + /** + * When true, returns only archived memories (archived_at IS NOT NULL). + * When false/absent, returns only active memories (archived_at IS NULL). + */ + showArchived?: boolean; + /** + * Narrow to memories whose TTL runs out within N days — the route's + * `expiring_within_days` (1–365). Absent means no expiry narrowing. + * + * Passed straight through and bounded by the route, not here: an + * out-of-range value is a 400 the caller should see, and re-implementing the + * bound in the dashboard would be a second copy of it to keep in step. + */ + expiringWithinDays?: number; +} + +/** + * `total` — exact count of every memory the request's filters matched, + * ignoring pagination (`MemoryPageResponseSchema`'s optional field, + * `lorekit_memory_list`'s `total_count` column, migration 00094). `Page` + * itself stays total-less: it is shared with `useAuditLog`, which has no + * equivalent aggregate, so this is `MemoryPage`'s own addition rather than a + * field every keyset page now carries. + */ +export type MemoryPage = Page & { total?: number }; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 100; + +/** + * The `POST /memories/list` body for one keyset page of the memories the caller + * can see. + * + * Ordering defaults to `created_at desc` — `sort: 'created_at'` — not the + * route's `updated_at` default: a memory migrated with a backdated `created_at` + * belongs at its original position in the Explorer, which is the order the list + * has always been in. `filters.sort` swaps in `opened_count` ("Most chosen"). + * + * A body rather than a query string: the filter bar's dimensions are unbounded, + * and the query transport caps each one at 2048 characters — the ceiling that + * made the Explorer stop loading past ~50-75 selected values in a dimension. + */ +export function memoryListBody(filters: MemoryFilters = {}): Partial { + const pageSize = clampPageSize(filters.pageSize, { def: DEFAULT_PAGE_SIZE, max: MAX_PAGE_SIZE }); + const bounds = dateRangeBounds(filters.range); + + // The deprecated `tags` shorthand becomes a `label` filter with the `all` + // operator — exactly what it has always meant — unless the caller already + // supplied one, in which case the explicit bar wins. + const legacyTags = normalizeTags(filters.tags); + const explicit = normalizeFilters(filters.filters ?? []); + const bar = + legacyTags.length === 0 || explicit.some((f) => f.field === 'label') + ? explicit + : normalizeFilters([...explicit, { field: 'label', operator: 'all', values: legacyTags }]); + + return { + limit: pageSize, + sort: filters.sort ?? DEFAULT_EXPLORER_SORT, + archived: filters.showArchived ?? false, + ...(filters.expiringWithinDays !== undefined + ? { expiring_within_days: filters.expiringWithinDays } + : {}), + ...(filters.scope ? { scope: filters.scope } : {}), + ...(filters.search ? { q: filters.search } : {}), + ...(bounds.gte ? { created_since: bounds.gte } : {}), + ...(bounds.lt ? { created_until: bounds.lt } : {}), + // OR within a dimension, AND across dimensions — see `filtersToBody`. + ...filtersToBody(bar), + ...retentionConditionsToListBody(filters.retentionConditions ?? {}), + ...(filters.cursor ? { cursor: filters.cursor } : {}), + }; +} + +/** A `POST /memories/list` response as the page shape the Explorer renders. */ +export function memoryPageFromResponse(page: MemoryPageResponse): MemoryPage { + return { + rows: page.entries.map(lessonFromMemoryEntry), + nextCursor: page.nextCursor, + hasMore: page.hasMore, + total: page.total, + }; +} diff --git a/packages/web/src/lib/queries/lore.ts b/packages/web/src/lib/queries/lore.ts index d141c17b8..eed22da60 100644 --- a/packages/web/src/lib/queries/lore.ts +++ b/packages/web/src/lib/queries/lore.ts @@ -16,7 +16,13 @@ import { dayCountsFromActivity } from '@/lib/aggregations'; import { heatmapSince } from '@/lib/heatmap-window'; import type { ScopeNode } from '@/components/lore/ScopeTree'; import type { LessonEntry } from '@/components/lore/LessonCard'; -import { listMemories, archiveLesson, restoreLesson, type MemoryFilters, type MemoryPage } from '@/lib/lore'; +import { archiveLesson, restoreLesson } from '@/lib/lore'; +import { + memoryListBody, + memoryPageFromResponse, + type MemoryFilters, + type MemoryPage, +} from '@/lib/memory-list'; import { DEFAULT_EXPLORER_SORT, type ExplorerSort } from '@/lib/explorer-sort'; import { NO_RETENTION_CONDITIONS, @@ -33,6 +39,7 @@ import { getMemoryByIdRequest, getMemoryByRefRequest, listFacetsPostRequest, + listMemoriesPostRequest, listMemoriesRequest, listScopesRequest, pivotPostRequest, @@ -613,7 +620,29 @@ function mergedFilters(filters: UseMemoriesFilters): Filter[] { } /** - * `useInfiniteQuery` over the `listMemories` server action. + * One page of the Explorer's list, straight from `POST /memories/list`. + * + * A browser request, not a server action, and that is the point. Next runs + * server actions one at a time per client, so the list used to queue behind + * the top bar's plan-usage and invites reads on every load and every window + * focus, arriving last while the header's activity bar stayed lit. A server + * action also cannot take an AbortSignal, so a page superseded by a filter + * change ran to completion anyway. This one runs in parallel with the other + * reads and is cancelled when React Query drops it. + * + * Unlike the old action it does not fail closed to an empty page: an API error + * throws, so the Explorer shows "Failed to load memories" instead of passing an + * outage off as "No memories", and a lapsed session is a + * `NotAuthenticatedError`, like every other read hook in this file. + */ +async function fetchMemoryPage(filters: MemoryFilters, signal?: AbortSignal): Promise { + const token = await requireBrowserToken(); + const page = await listMemoriesPostRequest(token, memoryListBody(filters), signal); + return memoryPageFromResponse(page); +} + +/** + * `useInfiniteQuery` over `POST /memories/list` ({@link fetchMemoryPage}). * * Changing any filter key resets pagination automatically because the query * key changes — no manual "reset cursor on filter change" bookkeeping needed. @@ -649,7 +678,7 @@ export function useMemories(filters: UseMemoriesFilters) { // under another — so the sort must split the cache, not share it. filters.sort ?? DEFAULT_EXPLORER_SORT, ], - queryFn: ({ pageParam }) => { + queryFn: ({ pageParam, signal }) => { const args: MemoryFilters = { scope: filters.scope ?? undefined, search: filters.search || undefined, @@ -661,12 +690,13 @@ export function useMemories(filters: UseMemoriesFilters) { showArchived: filters.showArchived, expiringWithinDays: filters.expiringWithinDays, }; - return listMemories(args); + return fetchMemoryPage(args, signal); }, initialPageParam: null as string | null, getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined), // Lore explorer is read-heavy — 90 s matches the scope-tree stale time. staleTime: 90_000, + retry: retryUnlessSignedOut, }); } diff --git a/packages/web/src/lib/queries/pending-invites.ts b/packages/web/src/lib/queries/pending-invites.ts index fff57c746..b3c4772b0 100644 --- a/packages/web/src/lib/queries/pending-invites.ts +++ b/packages/web/src/lib/queries/pending-invites.ts @@ -1,5 +1,6 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query'; -import { listPendingInvitesForMe, type OrgInvite } from '@/lib/org-invites'; +import type { OrgInvite } from '@/lib/org-invites'; +import { getAppRoute } from '@/lib/app-route'; /** * Shared TanStack query key for pending invites — the ONE cache entry consumed @@ -9,14 +10,24 @@ import { listPendingInvitesForMe, type OrgInvite } from '@/lib/org-invites'; */ export const PENDING_INVITES_QUERY_KEY = ['pending-invites'] as const; +/** The route handler (`app/api/invites/pending/route.ts`) the query reads. */ +export const PENDING_INVITES_PATH = '/api/invites/pending'; + /** * Shared query hook — SSR-seeded via `initialData` so there is exactly ONE * cache entry rather than independent fetches per consumer. + * + * Read through `GET /api/invites/pending` rather than by calling the server + * action: Next runs server actions one at a time per client, so this read used + * to queue with the plan-usage badge and the Explorer's list on every `/lore` + * load and window focus, and could not be cancelled (see `lib/app-route.ts`). + * The server-rendered pages still call `listPendingInvitesForMe` directly — on + * the server it is a plain function call, with no queue. */ export function usePendingInvitesForMe(initialData?: OrgInvite[]): UseQueryResult { return useQuery({ queryKey: PENDING_INVITES_QUERY_KEY, - queryFn: () => listPendingInvitesForMe(), + queryFn: ({ signal }) => getAppRoute(PENDING_INVITES_PATH, signal), initialData, staleTime: 60_000, }); diff --git a/packages/web/src/lib/queries/plan.ts b/packages/web/src/lib/queries/plan.ts index a6c67f139..3df7a92e8 100644 --- a/packages/web/src/lib/queries/plan.ts +++ b/packages/web/src/lib/queries/plan.ts @@ -1,7 +1,11 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { getPlanUsage } from '@/lib/plan'; +import type { PlanUsage } from '@/lib/plan'; +import { getAppRoute } from '@/lib/app-route'; + +/** The route handler (`app/api/plan/usage/route.ts`) the badge reads. */ +export const PLAN_USAGE_PATH = '/api/plan/usage'; /** * The caller's total memory count — the SAME figure the /settings/plan page @@ -18,11 +22,17 @@ import { getPlanUsage } from '@/lib/plan'; * `getPlanUsage` returns count + limit + plan in one round-trip; the badge needs * only the count. Degrades to 0 when there is no session / the RPC fails, matching * the plan page's graceful-null handling. + * + * Read through `GET /api/plan/usage` rather than by calling the server action: + * Next runs server actions one at a time per client, so this badge used to hold + * the Explorer's list and the invites read behind it on every `/lore` load and + * window focus, and none of them could be cancelled (see `lib/app-route.ts`). */ export function useMemoryTotal() { return useQuery({ queryKey: ['memory-total'], - queryFn: async () => (await getPlanUsage())?.count ?? 0, + queryFn: async ({ signal }) => + (await getAppRoute(PLAN_USAGE_PATH, signal))?.count ?? 0, // Changes only when an agent writes — align with the other read-heavy hooks. staleTime: 60_000, }); From 8f08fd3f954d3a1555d47b930201db3ebd7d8ea3 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 18:12:21 +0000 Subject: [PATCH 02/13] test(web): pin parallel, abortable /lore reads; document the no-server-action-reads rule - parallel-reads.spec.ts: useMemories, useMemoryTotal and usePendingInvitesForMe issue ordinary requests with React Query's signal and never call a server action (the action modules are mocked to throw) - read-routes.spec.ts: the two GET route handlers keep the wrapped functions' contract (null / [] when signed out) and are never cached - memory-list.spec.ts pins the POST /memories/list body - packages/web/CLAUDE.md, docs/decisions.md, CLAUDE.md: a client read is never a server action; docs/key-files.md and docs/storybook.md updated - LorePage story now renders the populated list (baseline needs refresh) Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- CLAUDE.md | 1 + docs/decisions.md | 4 + docs/key-files.md | 3 + docs/storybook.md | 9 +- packages/web/CLAUDE.md | 28 +++ .../app/(dashboard)/lore/LorePage.stories.tsx | 23 +-- packages/web/src/app/api/read-routes.spec.ts | 72 +++++++ .../web/src/components/lore/LoreExplorer.tsx | 2 +- packages/web/src/lib/memory-list.spec.ts | 101 ++++++++++ .../lib/queries/lore-scopes-dedupe.spec.ts | 1 + .../src/lib/queries/parallel-reads.spec.ts | 184 ++++++++++++++++++ packages/web/src/mocks/memories.ts | 12 +- 12 files changed, 414 insertions(+), 26 deletions(-) create mode 100644 packages/web/src/app/api/read-routes.spec.ts create mode 100644 packages/web/src/lib/memory-list.spec.ts create mode 100644 packages/web/src/lib/queries/parallel-reads.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 831c11f86..d32c458db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -390,6 +390,7 @@ Each decision's full rationale lives in [`docs/decisions.md`](./docs/decisions.m the headline here is the rule; follow the link for the "why". Short entries carry their rationale inline. **Do not relitigate these.** - **Dashboard is a CLIENT of LoreKit's REST API** — [rationale](./docs/decisions.md#dashboard-is-a-client-of-lorekits-rest-api) +- **Client reads are never server actions** (Next runs actions one at a time and they cannot be aborted) — [rationale](./docs/decisions.md#client-reads-are-never-server-actions) - **MCP server endpoint is a static production URL** — [rationale](./docs/decisions.md#mcp-server-endpoint-is-a-static-production-url) - **Lore Explorer filters through ONE two-level command menu + pills** — [rationale](./docs/decisions.md#lore-explorer-filters-through-one-two-level-command-menu) - **The Explorer's Activity panel has a DISPLAY default (24h), separate from the list's (all time)** — [rationale](./docs/decisions.md#the-explorers-activity-panel-has-a-display-default-separate-from-the-lists) diff --git a/docs/decisions.md b/docs/decisions.md index 505f7c00f..8a60bad57 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -857,3 +857,7 @@ score stays in `lesson-rank.ts` / `lessons-pure.mjs`. The ranking is set-relativ normalises against the most-recurring candidate) and it has to agree exactly with the CLI hook's ordering — an agreement `lesson-rank-parity.spec.ts` can hold two TypeScript-family implementations to and could not hold a plpgsql third copy to at all. + +## Client reads are never server actions + +**A React Query read in `packages/web` must not call a server action.** Next's app router runs server actions one at a time per client (the router's action queue starts the next one only when the previous resolves), and an action cannot take an `AbortSignal`. A read served by one queues behind every other action in flight, cannot be cancelled when its query is superseded, and keeps `useIsFetching()` (and so the TopBar activity bar) non-zero until the queue drains. It was measured, not theorised: on `/lore` the memory badge (`getPlanUsage`), the pending-invites query (`listPendingInvitesForMe`) and the Explorer list (`listMemories`) were all actions. Every load and every window focus (`FocusRefetcher`) ran them strictly in that order, each `POST /lore` starting the millisecond the previous one ended. The list arrived last, and the bar stayed on for seconds after the parallel REST panels had loaded. In the worst case seen, a 50 s Supabase `auth/v1/user` call inside the list action, plus a focus refetch queued behind it, kept the bar on for about 85 s. The fix is transport only: the list calls `POST /memories/list` from the browser (`fetchMemoryPage`, body from the pure `lib/memory-list.ts`), and the two reads with no REST endpoint yet go through GET route handlers (`/api/plan/usage`, `/api/invites/pending`) read with `getAppRoute(path, signal)`. Moving those two onto proper edge endpoints is still the backlog `packages/web/CLAUDE.md` names; the route handlers are the step that removes the queue without a schema/handler/OpenAPI change. Server actions remain right for mutations and for server components, where calling the function is a plain call. Guarded by `lib/queries/parallel-reads.spec.ts`, which mocks the action modules to throw. diff --git a/docs/key-files.md b/docs/key-files.md index 5fb603597..3e617cb5f 100644 --- a/docs/key-files.md +++ b/docs/key-files.md @@ -115,6 +115,9 @@ else. Grouped loosely by subsystem in migration/PR order. | `packages/mcp-core/src/auth/tenant-scope-usage.spec.ts` | Source-scan drift guard: asserts every edge `tools.ts` read handler (`toolRead`/`toolList`/`toolSearch`/`toolListArchived`) routes through `applyTenantScope`, not an inlined `.eq('user_id', ...)` | | `packages/web/src/lib/pagination/` | Pure, audit-decoupled keyset-pagination + filter module: `cursor.ts` (opaque base64url cursor codec, fails closed to `null` on any malformed/forged input — safe because callers still apply their own `user_id` scoping), `keyset.ts` (`clampPageSize`, the `(created_at desc, id desc)` `.or()` predicate, fetch+1 → page assembly), `filters.ts` (`normalizeActions` against a caller-supplied allow-set, LIKE/PostgREST-escaping `substringNeedle`, inclusive-`from`/half-open-`to` `dateRangeBounds`), `apply.ts` (thin supabase-js query-builder shell). Each pure file has a co-located `.spec.ts` (mirrors `aggregations.spec.ts`/`repo-format.spec.ts`); composed by `listAuditLog` in `audit-log.ts`, the only current consumer | | `packages/web/src/lib/queries/audit-log.ts` | `useAuditLog` — `useInfiniteQuery` over the `listAuditLog` server action, keyed by the URL-backed filter state (action set / name / date range); drives the feed's "Load more" | +| `packages/web/src/lib/memory-list.ts` | Pure Lore Explorer list request: `MemoryFilters` → the `POST /memories/list` body (`memoryListBody`: default `created_at` sort, page-size clamp, legacy `tags` folded into a `label` filter, retention thresholds) and the response → `MemoryPage` (`memoryPageFromResponse`). Moved out of the former `listMemories` server action when `useMemories` became a browser request; `memory-list.spec.ts` pins the body | +| `packages/web/src/lib/app-route.ts` | `getAppRoute(path, signal)` — the browser GET for the dashboard's own `app/api/**` route handlers (same-origin cookie auth, `no-store`, throws `AppRouteError` on non-2xx). Used for reads that still need server code but must not be server actions (see `packages/web/CLAUDE.md` → "A client read is never a server action") | +| `packages/web/src/app/api/plan/usage/route.ts`, `packages/web/src/app/api/invites/pending/route.ts` | GET route handlers wrapping `getPlanUsage` / `listPendingInvitesForMe` for `useMemoryTotal` and `usePendingInvitesForMe`. Same contract as the functions (`null` / `[]` when signed out or on failure), `force-dynamic`, `no-store`. `lib/queries/parallel-reads.spec.ts` asserts the hooks read them with React Query's signal and never call the server actions | | `supabase/migrations/00012_audit_log_search.sql` | Enables `pg_trgm` + a GIN trigram index on `audit_log.target` (index-backed `ilike` name search) and a `(user_id, created_at desc, id)` index covering the keyset seek (00010's `(user_id, created_at desc)` index lacks the `id` tiebreaker) | | `supabase/migrations/00017_org_roles_and_author.sql` | Adds `viewer` to the `org_members.role` CHECK; adds `memories.created_by`/`updated_by`; creates `lorekit_org_role` + `lorekit_org_can(user, org, capability)` — the single `SECURITY DEFINER` capability source for org writes/deletes (org-sharing Phase 2; see plan `.agent/claude/org-sharing-phase-2-writes/plan.md`) | | `supabase/migrations/00018_org_limits.sql` | `org_limits` table (mirrors `user_limits`) + `lorekit_get_org_limit`; re-creates `enforce_memory_cap()` with an org branch that runs BEFORE the `user_id IS NULL` service exemption, so an org-owned row (always `user_id NULL`) is never cap-exempt | diff --git a/docs/storybook.md b/docs/storybook.md index 623daec34..b5a595ceb 100644 --- a/docs/storybook.md +++ b/docs/storybook.md @@ -63,10 +63,11 @@ Shipped examples: Client pages (`'use client'`) render as true full-page stories under MSW. Server-component pages cannot — story their largest client subtree, don't refactor an RSC page to client just -to story it. One honest edge: the `/lore` lesson list reads the `listMemories` **server -action**, which is gated on `supabase.auth.getUser()`; with no session in the mocked browser -context it returns an empty page, so the results panel shows its empty state while the rest of -the page populates. Every part driven by a *browser* fetch is populated. +to story it. Every part driven by a *browser* fetch is populated, which on `/lore` now includes +the lesson list (`useMemories` reads `POST /memories/list` from the browser). A read that still +goes through a **server action** cannot be served this way: the action runs with no session in +the mocked context and returns its signed-out shape, so its panel shows an empty state. That is +one more reason reads do not belong in server actions (see `packages/web/CLAUDE.md`). ### Determinism diff --git a/packages/web/CLAUDE.md b/packages/web/CLAUDE.md index fc4195d0c..3d5bac627 100644 --- a/packages/web/CLAUDE.md +++ b/packages/web/CLAUDE.md @@ -84,6 +84,34 @@ or `.rpc(…)` for anything other than **auth**, stop and add an endpoint instea are the migration backlog, not a licence to add a new direct `.from(…)`/`.rpc(…)` read for product data — new surfaces get an endpoint. +## A client read is never a server action (REQUIRED) + +React Query `queryFn`s must not call a server action (`'use server'` module). +Next's app router runs server actions **one at a time per client**, and an +action cannot take an `AbortSignal`. Every read served that way queues behind +whatever action is already in flight, and React Query can never cancel it. +`useIsFetching()`, and with it the TopBar `ActivityIndicator`, stays non-zero +until the whole queue drains. + +- **This happened.** On `/lore` the memory badge, the pending-invites query and + the Explorer's list were all server actions, fired on every load and every + window focus (`FocusRefetcher`). They ran strictly in sequence, with the list + last, so the activity bar stayed lit for seconds after every visible panel had + loaded. In the worst case seen, a slow Supabase auth call kept it on for about + 85 s. +- **What to use instead.** If a REST endpoint exists, call it from the browser + through `src/lib/api/*` with the query's `signal` (`fetchMemoryPage`, + `fetchScopes`). If the read still needs server-side code (a surface on the + migration backlog), wrap it in a GET route handler under `app/api/**` and read + it with `getAppRoute(path, signal)` (`lib/app-route.ts`), as + `/api/plan/usage` and `/api/invites/pending` do. +- **Server actions stay for mutations**, which are one user gesture each, and + for server components, where calling the function is a plain call with no + queue. +- **Known remaining offenders** (not on `/lore`'s refresh path): + `useAuditLog` → `listAuditLog`, and `LessonDetailSheet`'s member lookup → + `listMemberIdentities`. + ## Conventions specific to this package - **Theme:** dark-only, driven by CSS custom properties in diff --git a/packages/web/src/app/(dashboard)/lore/LorePage.stories.tsx b/packages/web/src/app/(dashboard)/lore/LorePage.stories.tsx index 869d6b038..9b0556901 100644 --- a/packages/web/src/app/(dashboard)/lore/LorePage.stories.tsx +++ b/packages/web/src/app/(dashboard)/lore/LorePage.stories.tsx @@ -17,19 +17,15 @@ import { * mirroring `/insights`) that a server component cannot render in the browser — * so this story mounts its extracted `'use client'` body, `LorePageClient`, * directly, the same convention `/insights` uses for `InsightsPage`. - * `LorePageClient` drives its scope tree and contribution heatmap from - * `useScopeTree` / `useLoreData` — TanStack Query hooks that call the Supabase - * *browser* client (PostgREST over HTTP), which MSW mocks. So the component - * renders end-to-end here, not a hand-assembled subtree. + * `LorePageClient` drives its scope tree, contribution heatmap and lesson list + * from TanStack Query hooks (`useScopeTree`, `useLoreData`, `useMemories`) that + * call the `memories` REST function from the browser, which MSW mocks. So the + * component renders end-to-end here, not a hand-assembled subtree. * - * The lesson list inside `LoreExplorer` reads the `listMemories` **server - * action**. With `next/headers` auto-mocked by `@storybook/nextjs-vite` it does - * execute in the browser, but it is gated on `supabase.auth.getUser()`, which - * short-circuits to no session in the mocked context and returns an empty page — - * so the results panel renders its "No memories in this scope" empty state while - * the scope tree + heatmap populate from the MSW-mocked PostgREST reads. That is - * the honest edge of the mixed rendering model: the component renders end-to-end, - * and every part that is driven by a *browser* fetch is populated. + * The lesson list used to read the `listMemories` server action, which had no + * session in the mocked context and returned an empty page, so this story + * showed "No memories in this scope". The list is a browser request now + * (`POST /memories/list`), so it populates from the same fixtures as the tree. * * Determinism: the clock is frozen to {@link FROZEN_NOW} (heatmap + freshness are * time-relative) and the query client retries off / never refetches. @@ -63,8 +59,7 @@ type Story = StoryObj; /** * Populated Lore Explorer: the scope tree, contribution heatmap, view-mode tabs, - * and filter bars all resolve from the MSW-mocked dataset. The lesson results - * panel shows its empty state (see the auth-gate note above). + * filter bars and the lesson list all resolve from the MSW-mocked dataset. */ export const Default: Story = {}; diff --git a/packages/web/src/app/api/read-routes.spec.ts b/packages/web/src/app/api/read-routes.spec.ts new file mode 100644 index 000000000..3a3f89513 --- /dev/null +++ b/packages/web/src/app/api/read-routes.spec.ts @@ -0,0 +1,72 @@ +/** + * `GET /api/plan/usage` and `GET /api/invites/pending` are transports, not new + * logic: each wraps an existing server function so the browser can read it as + * an ordinary, parallel, abortable request instead of a queued server action. + * Pinned here: they answer exactly what the function returns (including its + * signed-out / failure shape) and are never cached. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getPlanUsage = vi.fn(); +const listPendingInvitesForMe = vi.fn(); + +vi.mock('@/lib/plan', () => ({ getPlanUsage: () => getPlanUsage() })); +vi.mock('@/lib/org-invites', () => ({ + listPendingInvitesForMe: () => listPendingInvitesForMe(), +})); + +const planUsageRoute = await import('./plan/usage/route'); +const pendingInvitesRoute = await import('./invites/pending/route'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GET /api/plan/usage', () => { + it('answers the caller plan usage as JSON, uncached', async () => { + getPlanUsage.mockResolvedValue({ count: 3318, limit: 5000, plan: 'free' }); + + const response = await planUsageRoute.GET(); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual({ count: 3318, limit: 5000, plan: 'free' }); + }); + + it('answers null when there is no session or the RPC failed, as the action did', async () => { + getPlanUsage.mockResolvedValue(null); + + const response = await planUsageRoute.GET(); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toBeNull(); + }); + + it('is rendered per request, never statically', () => { + expect(planUsageRoute.dynamic).toBe('force-dynamic'); + }); +}); + +describe('GET /api/invites/pending', () => { + it('answers the invites addressed to the caller as JSON, uncached', async () => { + const invites = [{ id: 'inv-1', org_id: 'org-1', status: 'pending', org: { name: 'Acme', slug: 'acme' } }]; + listPendingInvitesForMe.mockResolvedValue(invites); + + const response = await pendingInvitesRoute.GET(); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual(invites); + }); + + it('answers [] when there is no session or the query failed, as the action did', async () => { + listPendingInvitesForMe.mockResolvedValue([]); + + await expect((await pendingInvitesRoute.GET()).json()).resolves.toEqual([]); + }); + + it('is rendered per request, never statically', () => { + expect(pendingInvitesRoute.dynamic).toBe('force-dynamic'); + }); +}); diff --git a/packages/web/src/components/lore/LoreExplorer.tsx b/packages/web/src/components/lore/LoreExplorer.tsx index 2bdb24dad..e18aeaf0b 100644 --- a/packages/web/src/components/lore/LoreExplorer.tsx +++ b/packages/web/src/components/lore/LoreExplorer.tsx @@ -10,7 +10,7 @@ * - Default view is "all scopes" (no scope selected). The scope selector's first * chip is "All scopes", which clears the scope filter. * - Filtering (scope / search / date) is server-side, not client-side. - * `useMemories` (`useInfiniteQuery` over `listMemories`) is the data source. + * `useMemories` (`useInfiniteQuery` over `POST /memories/list`) is the data source. * - Pagination: "Load more" button appends the next keyset page, identical to * the audit log feed (`AuditLogFeed.tsx`). * - Scope is a persistent chip row (`ScopeSelector`) at the top of the page, diff --git a/packages/web/src/lib/memory-list.spec.ts b/packages/web/src/lib/memory-list.spec.ts new file mode 100644 index 000000000..170c94396 --- /dev/null +++ b/packages/web/src/lib/memory-list.spec.ts @@ -0,0 +1,101 @@ +/** + * `memoryListBody` / `memoryPageFromResponse` are the parts of the old + * `listMemories` server action that were never about transport. These pin the + * body it used to send, so moving the request into the browser changed where + * the list is fetched, not what is asked for. + */ + +import { describe, expect, it } from 'vitest'; +import type { MemoryEntry, MemoryPageResponse } from '@lorekit/schemas/memory'; +import { memoryListBody, memoryPageFromResponse } from './memory-list'; + +describe('memoryListBody', () => { + it('asks for one page of active memories, newest first, when nothing is filtered', () => { + expect(memoryListBody()).toEqual({ limit: 50, sort: 'created_at', archived: false }); + }); + + it('clamps the page size to the route maximum', () => { + expect(memoryListBody({ pageSize: 500 }).limit).toBe(100); + expect(memoryListBody({ pageSize: 0 }).limit).toBe(50); + }); + + it('sends scope, search, archived view, expiry horizon and cursor under their wire names', () => { + expect( + memoryListBody({ + scope: 'repo::mthines/lorekit', + search: 'flaky', + showArchived: true, + expiringWithinDays: 7, + cursor: 'abc', + sort: 'opened_count', + }), + ).toEqual({ + limit: 50, + sort: 'opened_count', + archived: true, + expiring_within_days: 7, + scope: 'repo::mthines/lorekit', + q: 'flaky', + cursor: 'abc', + }); + }); + + it('leaves out empty scope and search rather than sending blanks', () => { + const body = memoryListBody({ scope: null, search: '' }); + expect(body).not.toHaveProperty('scope'); + expect(body).not.toHaveProperty('q'); + }); + + it('turns an inclusive day range into a half-open created_at window', () => { + expect(memoryListBody({ range: { from: '2026-07-01', to: '2026-07-26' } })).toMatchObject({ + created_since: '2026-07-01', + created_until: '2026-07-27T00:00:00.000Z', + }); + }); + + it('folds the deprecated tags shorthand into an all-of label filter', () => { + expect(memoryListBody({ tags: ['ci', 'flaky'] })).toMatchObject({ + tags: ['ci', 'flaky'], + tags_mode: 'all', + }); + }); + + it('lets an explicit label filter win over the tags shorthand', () => { + const body = memoryListBody({ + tags: ['ignored'], + filters: [{ field: 'label', operator: 'in', values: ['kept'] }], + }); + expect(body).toMatchObject({ tags: ['kept'], tags_mode: 'any' }); + }); + + it('carries retention thresholds so the list matches what a policy would catch', () => { + expect(memoryListBody({ retentionConditions: { minAgeDays: 30, maxReadCount: 0 } })).toMatchObject({ + min_age_days: 30, + max_read_count: 0, + }); + }); +}); + +describe('memoryPageFromResponse', () => { + it('keeps the keyset envelope and the total, and maps every entry to a lesson row', () => { + const entry = { + id: '00000000-0000-0000-0000-000000000001', + scope: 'global', + key: 'k', + value: 'v', + tags: [], + } as unknown as MemoryEntry; + const response = { + entries: [entry], + hasMore: true, + nextCursor: 'next', + total: 7, + } as unknown as MemoryPageResponse; + + const page = memoryPageFromResponse(response); + + expect(page).toMatchObject({ hasMore: true, nextCursor: 'next', total: 7 }); + expect(page.rows).toHaveLength(1); + expect(page.rows[0]).toMatchObject({ id: entry.id, scope: 'global', key: 'k' }); + }); +}); diff --git a/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts b/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts index 23071e01c..ff194bcc2 100644 --- a/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts +++ b/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts @@ -40,6 +40,7 @@ vi.mock('@/lib/api/memories', () => ({ getMemoryByIdRequest: vi.fn(), getMemoryByRefRequest: vi.fn(), listFacetsPostRequest: vi.fn(), + listMemoriesPostRequest: vi.fn(), pivotPostRequest: vi.fn(), })); diff --git a/packages/web/src/lib/queries/parallel-reads.spec.ts b/packages/web/src/lib/queries/parallel-reads.spec.ts new file mode 100644 index 000000000..b7e9a1994 --- /dev/null +++ b/packages/web/src/lib/queries/parallel-reads.spec.ts @@ -0,0 +1,184 @@ +/** + * The three reads `/lore` fires on every load and every window-focus refetch — + * the Explorer's list, the top bar's memory badge and the pending-invites query + * — used to be Next.js server actions. The app router runs server actions one + * at a time per client, and a server action cannot take an AbortSignal, so the + * three queued (badge → invites → list) and kept the header's activity bar lit + * long after every visible panel had loaded. + * + * Pinned here: each hook now issues an ordinary request that carries React + * Query's signal, and none of them reaches for a server action. The server + * action modules are mocked to THROW, so a hook that regressed to calling one + * fails loudly instead of passing against a stub. + * + * No DOM environment in this package, so the hooks run as plain functions with + * `useQuery` / `useInfiniteQuery` swapped for a recorder of their options — + * the same harness `lore-scopes-dedupe.spec.ts` uses. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface RecordedOptions { + queryKey: readonly unknown[]; + queryFn: (context: { pageParam?: unknown; signal: AbortSignal }) => Promise; +} + +const harness = vi.hoisted(() => ({ recorded: [] as RecordedOptions[] })); + +const api = vi.hoisted(() => ({ listMemoriesPostRequest: vi.fn() })); + +const calledServerAction = vi.hoisted(() => () => { + throw new Error('a read went through a server action'); +}); + +vi.mock('@/lib/plan', () => ({ getPlanUsage: calledServerAction })); +vi.mock('@/lib/org-invites', () => ({ listPendingInvitesForMe: calledServerAction })); +vi.mock('@/lib/lore', () => ({ + archiveLesson: calledServerAction, + restoreLesson: calledServerAction, +})); + +vi.mock('@/lib/api/session-browser', () => ({ + browserAccessToken: async () => 'test-token', +})); + +vi.mock('@/lib/api/memories', async (importOriginal) => ({ + ...(await importOriginal()), + listMemoriesPostRequest: api.listMemoriesPostRequest, +})); + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal(); + const record = (options: RecordedOptions) => { + harness.recorded.push(options); + return { data: undefined }; + }; + return { ...actual, useQuery: record, useInfiniteQuery: record }; +}); + +const { useMemories } = await import('./lore'); +const { useMemoryTotal, PLAN_USAGE_PATH } = await import('./plan'); +const { usePendingInvitesForMe, PENDING_INVITES_PATH } = await import('./pending-invites'); + +/** The options a hook hands React Query, captured by running it once. */ +function optionsOf(hook: () => unknown): RecordedOptions { + harness.recorded = []; + hook(); + const [options] = harness.recorded; + if (!options) throw new Error('the hook registered no query'); + return options; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useMemories', () => { + const listOptions = () => + optionsOf(() => useMemories({ scope: 'repo::mthines/lorekit', search: '', range: null })); + + it('reads POST /memories/list from the browser and hands it the query signal', async () => { + api.listMemoriesPostRequest.mockResolvedValue({ + entries: [], + hasMore: false, + nextCursor: null, + total: 0, + }); + const { signal } = new AbortController(); + + const page = await listOptions().queryFn({ pageParam: null, signal }); + + expect(api.listMemoriesPostRequest).toHaveBeenCalledWith( + 'test-token', + expect.objectContaining({ scope: 'repo::mthines/lorekit', limit: 50, sort: 'created_at' }), + signal, + ); + expect(page).toEqual({ rows: [], hasMore: false, nextCursor: null, total: 0 }); + }); + + it('sends the page cursor React Query passes for "Load more"', async () => { + api.listMemoriesPostRequest.mockResolvedValue({ entries: [], hasMore: false, nextCursor: null }); + + await listOptions().queryFn({ pageParam: 'cursor-2', signal: new AbortController().signal }); + + expect(api.listMemoriesPostRequest.mock.calls[0]?.[1]).toMatchObject({ cursor: 'cursor-2' }); + }); + + it('rejects on an API failure instead of passing it off as an empty list', async () => { + api.listMemoriesPostRequest.mockRejectedValue(new Error('upstream 503')); + + await expect( + listOptions().queryFn({ pageParam: null, signal: new AbortController().signal }), + ).rejects.toThrow('upstream 503'); + }); +}); + +describe('useMemoryTotal', () => { + it('reads the count from GET /api/plan/usage with the query signal', async () => { + fetchMock.mockResolvedValue(jsonResponse({ count: 3318, limit: 5000, plan: 'free' })); + const { signal } = new AbortController(); + + await expect(optionsOf(() => useMemoryTotal()).queryFn({ signal })).resolves.toBe(3318); + expect(fetchMock).toHaveBeenCalledWith( + PLAN_USAGE_PATH, + expect.objectContaining({ method: 'GET', cache: 'no-store', signal }), + ); + }); + + it('degrades to 0 when the route answers null (no session, or the RPC failed)', async () => { + fetchMock.mockResolvedValue(jsonResponse(null)); + + await expect( + optionsOf(() => useMemoryTotal()).queryFn({ signal: new AbortController().signal }), + ).resolves.toBe(0); + }); + + it('rejects on a non-2xx answer so React Query can retry it', async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: 'boom' }, 500)); + + await expect( + optionsOf(() => useMemoryTotal()).queryFn({ signal: new AbortController().signal }), + ).rejects.toThrow(`GET ${PLAN_USAGE_PATH} failed with HTTP 500`); + }); +}); + +describe('usePendingInvitesForMe', () => { + it('reads GET /api/invites/pending with the query signal', async () => { + const invites = [{ id: 'inv-1', org_id: 'org-1', status: 'pending' }]; + fetchMock.mockResolvedValue(jsonResponse(invites)); + const { signal } = new AbortController(); + + await expect(optionsOf(() => usePendingInvitesForMe()).queryFn({ signal })).resolves.toEqual( + invites, + ); + expect(fetchMock).toHaveBeenCalledWith( + PENDING_INVITES_PATH, + expect.objectContaining({ method: 'GET', cache: 'no-store', signal }), + ); + }); +}); + +describe('the paths the hooks read', () => { + it.each([PLAN_USAGE_PATH, PENDING_INVITES_PATH])('%s is served by a GET route handler', (path) => { + const file = fileURLToPath(new URL(`../../app${path}/route.ts`, import.meta.url)); + + expect(existsSync(file)).toBe(true); + expect(readFileSync(file, 'utf8')).toMatch(/export async function GET\(/); + }); +}); diff --git a/packages/web/src/mocks/memories.ts b/packages/web/src/mocks/memories.ts index 8d8beec95..d4235edc8 100644 --- a/packages/web/src/mocks/memories.ts +++ b/packages/web/src/mocks/memories.ts @@ -3,11 +3,9 @@ * Query hooks call. * * These back the full-page / full-view visual-regression stories. The hooks - * (`useScopeTree`, `useFacetCatalog`, `useLoreData`, and the - * `listMemories` server action, which — with `next/headers` auto-mocked by - * `@storybook/nextjs-vite` — executes in the browser) all talk to the - * `memories` edge function now rather than to PostgREST directly, so the - * handlers below mock `…/functions/v1/memories*`. + * (`useScopeTree`, `useFacetCatalog`, `useLoreData`, `useMemories`) all talk to + * the `memories` edge function from the browser rather than to PostgREST + * directly, so the handlers below mock `…/functions/v1/memories*`. * * ONE fixture set drives every consumer: `MEMORY_ROWS` is the "database", and * each handler derives its own response from it exactly as the real endpoint @@ -429,8 +427,8 @@ function filterRows(rows: MemoryRow[], url: URL): MemoryRow[] { function listFrom(rows: MemoryRow[], url: URL) { const limit = Number(url.searchParams.get('limit') ?? 50); // Honour `sort` rather than pinning one: the route defaults to `updated_at`, - // but `listMemories` (lib/lore.ts) always sends `sort: 'created_at'`, so the - // Explorer reads in creation order. A mock that pinned EITHER value would + // but `memoryListBody` (lib/memory-list.ts) always sends one, `created_at` + // by default, so the Explorer reads in creation order. A mock that pinned EITHER value would // render one order while the caller asked for another, which is the sort of // difference a screenshot baseline then freezes in place. const sort = url.searchParams.get('sort') === 'created_at' ? 'created_at' : 'updated_at'; From 21920140fa660bd289dec9f97e39dd3495531728 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 19:10:17 +0000 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20memory.quota=20=E2=80=94=20your?= =?UTF-8?q?=20memories=20against=20your=20plan=20cap=20on=20REST,=20MCP=20?= =?UTF-8?q?and=20the=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /memories/quota, the memory.quota MCP tool and `lorekit quota` all answer { plan, personal: { count, limit, remaining }, org_count, total } from the actor-guarded lorekit_memory_count RPC. personal is the pair the cap trigger enforces; org-owned memories count against each org's own cap, so they sit beside it instead of being subtracted (the RPC's combined count measured against the personal limit made 'remaining' wrong for both). The dashboard reads it over REST too: the TopBar badge (total) from the browser with React Query's signal, Settings > Plan (personal pair) on the server. The interim /api/plan/usage route and the direct-RPC getPlanUsage server action are removed. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- docs/cli.md | 13 +++ docs/decisions.md | 2 +- docs/key-files.md | 3 +- docs/limits.md | 12 +++ packages/cli/bin/lorekit.mjs | 30 ++++++ packages/cli/src/commands.mjs | 2 + packages/cli/src/commands/quota.mjs | 96 +++++++++++++++++++ packages/cli/src/shared/completions.mjs | 2 + packages/cli/src/shared/mirror-pairs.mjs | 1 + packages/cli/src/store/remote.mjs | 34 +++++++ packages/cli/src/surfaces.generated.mjs | 18 ++++ packages/cli/test/smoke/cases.mjs | 9 ++ packages/mcp-core/src/auth/permissions.ts | 3 + .../mcp-core/src/limits/memory-quota.spec.ts | 41 ++++++++ packages/mcp-core/src/limits/memory-quota.ts | 59 ++++++++++++ packages/mcp-core/src/rest/rest-tool-name.ts | 1 + .../mcp-core/src/telemetry/usage-stats.ts | 1 + packages/schemas/src/domain/memory.ts | 25 +++++ packages/schemas/src/openapi/spec.spec.ts | 3 + packages/schemas/src/openapi/spec.ts | 17 ++++ packages/schemas/src/shared/tool-catalog.ts | 22 +++++ packages/web/CLAUDE.md | 4 +- packages/web/public/llms.txt | 15 +++ .../app/(dashboard)/settings/plan/page.tsx | 27 +++++- packages/web/src/app/api/plan/usage/route.ts | 23 ----- packages/web/src/app/api/read-routes.spec.ts | 40 ++------ .../src/components/settings/PlanPeakUsage.tsx | 11 +-- .../src/components/settings/PlanUsageBar.tsx | 10 +- packages/web/src/content/docs/remote.mdx | 2 +- packages/web/src/lib/api/memories.ts | 13 +++ packages/web/src/lib/plan.ts | 51 ---------- .../src/lib/queries/parallel-reads.spec.ts | 38 ++++---- packages/web/src/lib/queries/plan.ts | 44 ++++----- scripts/smoke/smoke-mcp-tools.mjs | 14 +++ .../functions/_shared/limits/memory-quota.ts | 59 ++++++++++++ .../functions/_shared/rest/rest-tool-name.ts | 1 + supabase/functions/_shared/schemas/memory.ts | 25 +++++ .../functions/_shared/schemas/openapi/spec.ts | 17 ++++ .../functions/_shared/schemas/tool-catalog.ts | 22 +++++ supabase/functions/mcp/permissions.ts | 3 + .../functions/mcp/tool-dispatch.generated.ts | 2 + supabase/functions/mcp/tools.ts | 32 +++++++ supabase/functions/memories/handlers/quota.ts | 45 +++++++++ supabase/functions/memories/index.ts | 2 + 44 files changed, 722 insertions(+), 172 deletions(-) create mode 100644 packages/cli/src/commands/quota.mjs create mode 100644 packages/mcp-core/src/limits/memory-quota.spec.ts create mode 100644 packages/mcp-core/src/limits/memory-quota.ts delete mode 100644 packages/web/src/app/api/plan/usage/route.ts delete mode 100644 packages/web/src/lib/plan.ts create mode 100644 supabase/functions/_shared/limits/memory-quota.ts create mode 100644 supabase/functions/memories/handlers/quota.ts diff --git a/docs/cli.md b/docs/cli.md index c5d61cd5a..bc6490778 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -180,6 +180,19 @@ a denied/unconfigured/unreachable/erroring remote degrades to a short, accurate `describeError` at exit 0 [the old `REMOTE_SCOPES_UNSUPPORTED` sentinel is gone — the surface supports enumeration now] +### `quota` + +Remote-only: your active memories against your plan's cap and how many writes are left, from +`GET /memories/quota` (the same answer as the `memory.quota` MCP tool). `personal` is the pair the +cap trigger enforces — personal memories against the personal limit, `remaining` = limit − count, +never negative; org-owned memories count against each org's own cap, so they are shown beside it +(`org_count`) and never subtracted. The shaping is the pure `memoryQuotaFromRow` +(`packages/mcp-core/src/limits/memory-quota.ts`, mirrored to `_shared/limits/`). Warns at the cap +and under 10 % headroom. Unlike the other read commands it **exits 1** when the remote is +denied/unconfigured/unreachable/erroring, because there is no offline answer to fall back to and a +script gating a bulk write needs to see the failure; `--json` emits +`{ available, endpoint, plan, personal, org_count, total }` or `{ available: false, reason }` + ### `diff` Compare offline vs remote for the applicable scopes and report **local-only** / **remote-only** / diff --git a/docs/decisions.md b/docs/decisions.md index 8a60bad57..e2f234ab0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -860,4 +860,4 @@ implementations to and could not hold a plpgsql third copy to at all. ## Client reads are never server actions -**A React Query read in `packages/web` must not call a server action.** Next's app router runs server actions one at a time per client (the router's action queue starts the next one only when the previous resolves), and an action cannot take an `AbortSignal`. A read served by one queues behind every other action in flight, cannot be cancelled when its query is superseded, and keeps `useIsFetching()` (and so the TopBar activity bar) non-zero until the queue drains. It was measured, not theorised: on `/lore` the memory badge (`getPlanUsage`), the pending-invites query (`listPendingInvitesForMe`) and the Explorer list (`listMemories`) were all actions. Every load and every window focus (`FocusRefetcher`) ran them strictly in that order, each `POST /lore` starting the millisecond the previous one ended. The list arrived last, and the bar stayed on for seconds after the parallel REST panels had loaded. In the worst case seen, a 50 s Supabase `auth/v1/user` call inside the list action, plus a focus refetch queued behind it, kept the bar on for about 85 s. The fix is transport only: the list calls `POST /memories/list` from the browser (`fetchMemoryPage`, body from the pure `lib/memory-list.ts`), and the two reads with no REST endpoint yet go through GET route handlers (`/api/plan/usage`, `/api/invites/pending`) read with `getAppRoute(path, signal)`. Moving those two onto proper edge endpoints is still the backlog `packages/web/CLAUDE.md` names; the route handlers are the step that removes the queue without a schema/handler/OpenAPI change. Server actions remain right for mutations and for server components, where calling the function is a plain call. Guarded by `lib/queries/parallel-reads.spec.ts`, which mocks the action modules to throw. +**A React Query read in `packages/web` must not call a server action.** Next's app router runs server actions one at a time per client (the router's action queue starts the next one only when the previous resolves), and an action cannot take an `AbortSignal`. A read served by one queues behind every other action in flight, cannot be cancelled when its query is superseded, and keeps `useIsFetching()` (and so the TopBar activity bar) non-zero until the queue drains. It was measured, not theorised: on `/lore` the memory badge (`getPlanUsage`), the pending-invites query (`listPendingInvitesForMe`) and the Explorer list (`listMemories`) were all actions. Every load and every window focus (`FocusRefetcher`) ran them strictly in that order, each `POST /lore` starting the millisecond the previous one ended. The list arrived last, and the bar stayed on for seconds after the parallel REST panels had loaded. In the worst case seen, a 50 s Supabase `auth/v1/user` call inside the list action, plus a focus refetch queued behind it, kept the bar on for about 85 s. The fix is transport only: the list calls `POST /memories/list` from the browser (`fetchMemoryPage`, body from the pure `lib/memory-list.ts`), and the two reads with no REST endpoint yet go through GET route handlers (`/api/plan/usage`, `/api/invites/pending`) read with `getAppRoute(path, signal)`. Moving those two onto proper edge endpoints is still the backlog `packages/web/CLAUDE.md` names; the route handlers are the step that removes the queue without a schema/handler/OpenAPI change. Server actions remain right for mutations and for server components, where calling the function is a plain call. Guarded by `lib/queries/parallel-reads.spec.ts`, which mocks the action modules to throw. **Plan usage has since left the stopgap**: it is `GET /memories/quota`, with `memory.quota` on MCP and `lorekit quota` on the CLI — the one quota read agents get, not a breach of "Dashboard analytics reads stay REST-only": it is the caller's own headroom against the cap that refuses `memory.write`, which an agent can act on before a bulk write, not an aggregate for a chart. diff --git a/docs/key-files.md b/docs/key-files.md index 3e617cb5f..85ea8b23c 100644 --- a/docs/key-files.md +++ b/docs/key-files.md @@ -117,7 +117,8 @@ else. Grouped loosely by subsystem in migration/PR order. | `packages/web/src/lib/queries/audit-log.ts` | `useAuditLog` — `useInfiniteQuery` over the `listAuditLog` server action, keyed by the URL-backed filter state (action set / name / date range); drives the feed's "Load more" | | `packages/web/src/lib/memory-list.ts` | Pure Lore Explorer list request: `MemoryFilters` → the `POST /memories/list` body (`memoryListBody`: default `created_at` sort, page-size clamp, legacy `tags` folded into a `label` filter, retention thresholds) and the response → `MemoryPage` (`memoryPageFromResponse`). Moved out of the former `listMemories` server action when `useMemories` became a browser request; `memory-list.spec.ts` pins the body | | `packages/web/src/lib/app-route.ts` | `getAppRoute(path, signal)` — the browser GET for the dashboard's own `app/api/**` route handlers (same-origin cookie auth, `no-store`, throws `AppRouteError` on non-2xx). Used for reads that still need server code but must not be server actions (see `packages/web/CLAUDE.md` → "A client read is never a server action") | -| `packages/web/src/app/api/plan/usage/route.ts`, `packages/web/src/app/api/invites/pending/route.ts` | GET route handlers wrapping `getPlanUsage` / `listPendingInvitesForMe` for `useMemoryTotal` and `usePendingInvitesForMe`. Same contract as the functions (`null` / `[]` when signed out or on failure), `force-dynamic`, `no-store`. `lib/queries/parallel-reads.spec.ts` asserts the hooks read them with React Query's signal and never call the server actions | +| `packages/web/src/app/api/invites/pending/route.ts` | GET route handler wrapping `listPendingInvitesForMe` for `usePendingInvitesForMe` — a stopgap until invites have an edge endpoint. Same contract as the function (`[]` when signed out or on failure), `force-dynamic`, `no-store`. `lib/queries/parallel-reads.spec.ts` asserts the hook reads it with React Query's signal and never calls the server action | +| `packages/mcp-core/src/limits/memory-quota.ts` (← mirrored to `supabase/functions/_shared/limits/memory-quota.ts`) | `memoryQuotaFromRow` — shapes `lorekit_memory_count` (00047) into the quota contract behind `GET /memories/quota` (`memories/handlers/quota.ts`), the `memory.quota` MCP tool (`toolQuota`) and `lorekit quota`: the PERSONAL pair the cap trigger enforces plus `org_count` beside it, because the RPC's combined `count` is measured against the personal limit only | | `supabase/migrations/00012_audit_log_search.sql` | Enables `pg_trgm` + a GIN trigram index on `audit_log.target` (index-backed `ilike` name search) and a `(user_id, created_at desc, id)` index covering the keyset seek (00010's `(user_id, created_at desc)` index lacks the `id` tiebreaker) | | `supabase/migrations/00017_org_roles_and_author.sql` | Adds `viewer` to the `org_members.role` CHECK; adds `memories.created_by`/`updated_by`; creates `lorekit_org_role` + `lorekit_org_can(user, org, capability)` — the single `SECURITY DEFINER` capability source for org writes/deletes (org-sharing Phase 2; see plan `.agent/claude/org-sharing-phase-2-writes/plan.md`) | | `supabase/migrations/00018_org_limits.sql` | `org_limits` table (mirrors `user_limits`) + `lorekit_get_org_limit`; re-creates `enforce_memory_cap()` with an org branch that runs BEFORE the `user_id IS NULL` service exemption, so an org-owned row (always `user_id NULL`) is never cap-exempt | diff --git a/docs/limits.md b/docs/limits.md index 4b796bea5..8e1210e23 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -42,6 +42,18 @@ When a write would exceed the cap, the DB raises a custom error (SQLSTATE > delete unused memories, or upgrade your plan — see > https://lorekit.io (or contact support) to increase it." +### Reading your headroom before you hit it + +`GET /memories/quota` (REST), `memory.quota` (MCP) and `lorekit quota` (CLI) +all answer `{ plan, personal: { count, limit, remaining }, org_count, total }` +from `lorekit_memory_count` (00047), shaped by the pure `memoryQuotaFromRow` +(`packages/mcp-core/src/limits/memory-quota.ts`). `personal` is exactly the +pair the trigger above enforces — personal active rows against the personal +limit — so `remaining` is the number of new personal writes left. Org-owned +rows count against each org's own cap (`00018_org_limits.sql`), so they are +reported as `org_count` beside it and never subtracted. A service-role caller +has no user and is refused. Per-org quotas are not reported yet. + ### The cap is also a WRITE-COST parameter Raising it is not free, and not for the reason you would expect. The trigger diff --git a/packages/cli/bin/lorekit.mjs b/packages/cli/bin/lorekit.mjs index ea0f75d05..f98e7d750 100755 --- a/packages/cli/bin/lorekit.mjs +++ b/packages/cli/bin/lorekit.mjs @@ -66,6 +66,9 @@ ${c.bold('Commands')} with a memory count per scope — not cwd-scoped like the commands above (it lists scopes anywhere in the store). Offline is exact; the remote can't enumerate scopes (honest note). --json, --scope . + quota Show your active memories against your plan's cap and how many + writes are left (remote only). Exits 1 if the remote can't answer. + --json. diff Compare the offline and remote stores for the applicable scopes and report divergence: local-only, remote-only, and conflicting keys (grouped by scope). Needs both stores readable. --json, --scope . @@ -553,6 +556,33 @@ ${c.bold('Examples')} npx @lorekit/cli scopes npx @lorekit/cli scopes --json npx @lorekit/cli scopes --scope repo:: +`, + quota: `${c.bold('lorekit quota')} — your memories against your plan's cap + +${c.bold('Usage')} + npx @lorekit/cli quota [options] + +Shows how many active memories you hold against your plan's cap and how many +writes are left before the remote refuses one with \`memory_cap\` — from +\`GET /memories/quota\`, the same answer the \`memory.quota\` MCP tool gives. + +"personal" is the pair the cap enforces: your personal memories against your +personal limit. Org-owned memories count against each org's own cap, so they +are shown beside it and never subtracted from what is left. + +Remote only: the offline store has no plan and no cap. Exits 1 when the remote +is denied, unconfigured, unreachable or erroring, so a script can gate a bulk +write on it. + +${c.bold('Options')} + -d, --dir Target project root (default: current directory) + --json Machine-readable output + -e, --endpoint Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL) + -t, --token Remote token override (else .mcp.json / LOREKIT_TOKEN) + +${c.bold('Examples')} + npx @lorekit/cli quota + npx @lorekit/cli quota --json `, diff: `${c.bold('lorekit diff')} — compare the offline and remote stores diff --git a/packages/cli/src/commands.mjs b/packages/cli/src/commands.mjs index 96709a598..836a1ccdd 100644 --- a/packages/cli/src/commands.mjs +++ b/packages/cli/src/commands.mjs @@ -44,6 +44,7 @@ import { write } from './commands/write.mjs'; import { archive, del, restore } from './commands/remove.mjs'; import { stats } from './commands/stats.mjs'; import { scopes } from './commands/scopes.mjs'; +import { quota } from './commands/quota.mjs'; import { diff } from './commands/diff.mjs'; import { tree } from './commands/tree.mjs'; import { lint } from './commands/lint.mjs'; @@ -81,6 +82,7 @@ export const COMMANDS = [ { name: 'show', run: show, traced: true, strictFlags: true, tool: 'memory.read' }, { name: 'stats', run: stats, traced: true, strictFlags: true, native: 'local rollup over the resolved store' }, { name: 'scopes', run: scopes, traced: true, strictFlags: true, tool: 'memory.scopes' }, + { name: 'quota', run: quota, traced: true, strictFlags: true, tool: 'memory.quota' }, { name: 'diff', run: diff, traced: true, strictFlags: true, native: 'compares two scopes' }, { name: 'tree', run: tree, traced: true, strictFlags: true, native: 'resolves the scope hierarchy for a directory', aliases: ['resolve'] }, { name: 'lint', run: lint, traced: true, strictFlags: true, native: 'quality pass over stored lessons' }, diff --git a/packages/cli/src/commands/quota.mjs b/packages/cli/src/commands/quota.mjs new file mode 100644 index 000000000..91650d336 --- /dev/null +++ b/packages/cli/src/commands/quota.mjs @@ -0,0 +1,96 @@ +// `lorekit quota` — how many active memories you hold against your plan's cap, +// and how many writes are left before the remote refuses one with `memory_cap`. +// The CLI face of `GET /memories/quota` and the `memory.quota` MCP tool. +// +// Remote-only, unlike the other read commands: the offline store has no plan +// and no cap, so there is no Offline section to render. `personal` is the pair +// the cap enforces; org-owned memories count against each org's own cap, so +// they are shown beside it and never subtracted from what is left. +// +// Exits 1 when the remote cannot answer (denied, unconfigured, unreachable, or +// erroring): a script that gates a bulk write on this needs a failure it can +// see, and there is no partial answer to fall back to. `--json` emits +// `{ available, endpoint, plan, personal, org_count, total }` or +// `{ available: false, reason }`. +import process from 'node:process'; +import { resolveProjectRoot } from '../shared/config.mjs'; +import { resolveDenies } from '../shared/control.mjs'; +import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs'; +import { describeError } from '../shared/lessons-view.mjs'; +import { log, heading, status, c } from '../shared/util.mjs'; + +/** Headroom below this share of the cap earns a warning, so the cap is never a surprise. */ +const LOW_HEADROOM_RATIO = 0.1; + +export async function quota(args) { + const root = resolveProjectRoot(args.dir); + const env = { ...process.env }; + + const { remote, connection } = resolveStores(root, { + env, + endpoint: args.endpoint, + token: args.token, + }); + const { remoteDenied } = resolveDenies(root, { env }); + + let section; + if (remoteDenied) { + section = { available: false, reason: `disabled by deny constraint (${remoteDenied.source})` }; + } else if (!remote.usable()) { + section = { available: false, reason: remoteUnavailableReason(connection) }; + } else { + const res = await remote.quota(); + section = res.ok ? { available: true, ...res.quota } : { available: false, reason: describeError(res) }; + } + + if (args.json) { + log(JSON.stringify( + section.available ? { available: true, endpoint: connection.endpoint, ...withoutFlag(section) } : section, + null, + 2, + )); + } else { + render(section, remote.usable() ? connection.endpoint : undefined); + } + + // Bounded, non-PII telemetry extras: counts, never a scope, path or token. + return { + exitCode: section.available ? 0 : 1, + 'lorekit.cli.quota.available': section.available, + 'lorekit.cli.quota.personal_count': section.available ? section.personal.count : 0, + 'lorekit.cli.quota.personal_limit': section.available ? section.personal.limit : 0, + }; +} + +function withoutFlag({ available: _available, ...rest }) { + return rest; +} + +function render(section, endpoint) { + heading('LoreKit quota'); + if (endpoint) log(` ${c.dim(endpoint)}`); + + if (!section.available) { + status('warn', 'unavailable', section.reason); + log(''); + return; + } + + const { plan, personal, org_count: orgCount, total } = section; + log(` ${c.dim('plan'.padEnd(9))}${c.bold(plan)}`); + log( + ` ${c.dim('personal'.padEnd(9))}${c.bold(`${personal.count} / ${personal.limit}`)} ` + + c.dim(`(${personal.remaining} left)`), + ); + if (orgCount > 0) { + log(` ${c.dim('org'.padEnd(9))}${orgCount} ${c.dim("(counted against each org's own cap)")}`); + } + log(` ${c.dim('total'.padEnd(9))}${total}`); + + if (personal.limit > 0 && personal.remaining === 0) { + status('warn', 'at the cap', 'new personal writes are refused until you archive or delete some'); + } else if (personal.limit > 0 && personal.remaining < personal.limit * LOW_HEADROOM_RATIO) { + status('warn', 'near the cap', `${personal.remaining} personal writes left`); + } + log(''); +} diff --git a/packages/cli/src/shared/completions.mjs b/packages/cli/src/shared/completions.mjs index bcb7c5d92..38f1c49ba 100644 --- a/packages/cli/src/shared/completions.mjs +++ b/packages/cli/src/shared/completions.mjs @@ -137,6 +137,8 @@ const COMMANDS = [ flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store'] }, { name: 'scopes', summary: 'Inventory every distinct scope', flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store'] }, + { name: 'quota', summary: 'Show your memories against your plan cap', + flags: ['dir', 'json', 'endpoint', 'token'] }, { name: 'diff', summary: 'Compare the offline and remote stores', flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store'] }, { name: 'tree', summary: 'Show scope precedence and which memory wins', aliases: ['resolve'], diff --git a/packages/cli/src/shared/mirror-pairs.mjs b/packages/cli/src/shared/mirror-pairs.mjs index 2a15599cf..ef44caf82 100644 --- a/packages/cli/src/shared/mirror-pairs.mjs +++ b/packages/cli/src/shared/mirror-pairs.mjs @@ -73,6 +73,7 @@ export const mirrorPairs = [ { core: 'packages/mcp-core/src/limits/dry-run.ts', edge: 'supabase/functions/_shared/limits/dry-run.ts', driftChecked: true }, { core: 'packages/mcp-core/src/telemetry/usage-stats.ts', edge: 'supabase/functions/_shared/telemetry/usage-stats.ts', driftChecked: true }, { core: 'packages/mcp-core/src/limits/expiring-window.ts', edge: 'supabase/functions/_shared/limits/expiring-window.ts', driftChecked: true }, + { core: 'packages/mcp-core/src/limits/memory-quota.ts', edge: 'supabase/functions/_shared/limits/memory-quota.ts', driftChecked: true }, { core: 'packages/mcp-core/src/rest/cors-origins.ts', edge: 'supabase/functions/_shared/api/cors-origins.ts', driftChecked: true }, { core: 'packages/mcp-core/src/rest/count-cursor.ts', edge: 'supabase/functions/_shared/api/count-cursor.ts', driftChecked: true }, { core: 'packages/mcp-core/src/scope/scope-type-attribute.ts', edge: 'supabase/functions/_shared/scope/scope-type-attribute.ts', driftChecked: true }, diff --git a/packages/cli/src/store/remote.mjs b/packages/cli/src/store/remote.mjs index 6733362ff..829b1b31a 100644 --- a/packages/cli/src/store/remote.mjs +++ b/packages/cli/src/store/remote.mjs @@ -786,6 +786,40 @@ class RemoteStore { }; } + // The caller's memory quota — `GET /memories/quota` (the REST twin of the + // `memory.quota` MCP tool): `{ plan, personal: { count, limit, remaining }, + // org_count, total }`. `personal` is the pair the cap trigger enforces; org + // memories count against each org's own cap, which is why they sit beside it. + // Remote-only by nature: the offline store has no plan and no cap. Failures + // use this store's standard `{ ok:false, error, networkError }` envelope. + async quota() { + const res = await this._rest('/memories/quota'); + if (!res.ok) { + return { + ok: false, + error: res.error, + httpStatus: res.httpStatus, + networkError: res.networkError, + unusable: res.unusable, + }; + } + const q = res.data ?? {}; + const count = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0); + return { + ok: true, + quota: { + plan: typeof q.plan === 'string' ? q.plan : 'free', + personal: { + count: count(q.personal?.count), + limit: count(q.personal?.limit), + remaining: count(q.personal?.remaining), + }, + org_count: count(q.org_count), + total: count(q.total), + }, + }; + } + // ── Retention policies ("grooming") → REST ──────────────────────────────── // Server-side only (retention_policies table + pg_cron sweep) — v1 has no // local-store equivalent, matching the account-wide sweeps just above. diff --git a/packages/cli/src/surfaces.generated.mjs b/packages/cli/src/surfaces.generated.mjs index ff87df99a..26ac62d05 100644 --- a/packages/cli/src/surfaces.generated.mjs +++ b/packages/cli/src/surfaces.generated.mjs @@ -14,6 +14,7 @@ export const MCP_TOOL_NAMES = [ "memory.search", "memory.archive", "memory.scopes", + "memory.quota", "memory.list_archived", "memory.restore", "memory.purge", @@ -40,6 +41,7 @@ export const MEMORY_TOOL_NAMES = [ "memory.search", "memory.archive", "memory.scopes", + "memory.quota", "memory.list_archived", "memory.restore", "memory.purge", @@ -387,6 +389,20 @@ export const MCP_TOOL_DEFS = [ "openWorldHint": false } }, + { + "name": "memory.quota", + "description": "How many active memories you hold against your plan's cap, and how many writes are left before memory.write is refused with memory_cap. Takes no arguments. Check it before a bulk write or a migration instead of discovering the cap from a failed write.", + "inputSchema": { + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, { "name": "memory.list_archived", "description": "List archived (soft-deleted) lessons, for one scope or across every scope you can see", @@ -1413,6 +1429,7 @@ export const CLI_BINDINGS = { "search": "memory.search", "archive": "memory.archive", "scopes": "memory.scopes", + "quota": "memory.quota", "restore": "memory.restore", "purge": "memory.purge", "purge-expired": "memory.purge_expired", @@ -1446,6 +1463,7 @@ export const CLI_EXEMPT = { /** Op -> why the local stdio MCP server does not dispatch it. */ export const LOCAL_MCP_EXEMPT = { + "memory.quota": "server-side plan and memory cap; the offline store has no limit", "memory.list_archived": "reachable through memory.list's archived filter on the offline store", "memory.purge": "account-wide sweep against server-side state; the offline store has no equivalent", "memory.purge_expired": "account-wide sweep against server-side state; the offline store has no equivalent", diff --git a/packages/cli/test/smoke/cases.mjs b/packages/cli/test/smoke/cases.mjs index 78748e421..568ea8588 100644 --- a/packages/cli/test/smoke/cases.mjs +++ b/packages/cli/test/smoke/cases.mjs @@ -349,6 +349,15 @@ for (const [command, args] of REMOTE_ONLY) { // smoke does for the same tools. Preview's smoke token is service-role. const OWNERLESS = 'skipped: owner-keyed — a service-role credential has no owner'; +smoke('quota', 'reports the personal count against the plan cap', REMOTE, async ({ cli, ctx }) => { + if (ctx.tier === 'service') return OWNERLESS; + const q = cli.json(['quota']); + assert.equal(q.available, true, `quota is unavailable: ${q.reason}`); + assert.ok(q.personal.limit > 0, 'quota reports no personal limit'); + assert.equal(q.personal.remaining, Math.max(q.personal.limit - q.personal.count, 0), 'remaining is not limit minus count'); + assert.equal(q.total, q.personal.count + q.org_count, 'total is not personal + org'); +}); + smoke('protect', 'protects and unprotects a lesson', REMOTE, async ({ cli, ctx }) => { if (ctx.tier === 'service') return OWNERLESS; assert.equal(cli.json(['protect', `${ctx.scope}::${ctx.key('alpha')}`]).protected, true); diff --git a/packages/mcp-core/src/auth/permissions.ts b/packages/mcp-core/src/auth/permissions.ts index ef90e36ee..7649b98c5 100644 --- a/packages/mcp-core/src/auth/permissions.ts +++ b/packages/mcp-core/src/auth/permissions.ts @@ -29,6 +29,9 @@ export const READ_TOOLS: ReadonlySet = new Set([ // with records that are not lore. Permission gating and the records-read // metric are different questions about the same family. 'memory.scopes', + // Your own counts against your cap. Read-gated like every other read: a + // write-only token has no business learning the account's size. + 'memory.quota', // `org.list` is a read like any other. It used to be ungated because the org // tools were JWT-only; they now serve `lk_*` tokens, and listing the orgs you // belong to is exactly the shape of thing a read token should be able to do. diff --git a/packages/mcp-core/src/limits/memory-quota.spec.ts b/packages/mcp-core/src/limits/memory-quota.spec.ts new file mode 100644 index 000000000..fbb6fdf9b --- /dev/null +++ b/packages/mcp-core/src/limits/memory-quota.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { memoryQuotaFromRow } from './memory-quota'; + +describe('memoryQuotaFromRow', () => { + it('reports the personal pair the cap enforces, not the combined count', () => { + // The RPC's `count` is personal + org (4300) but its `limit` is the + // PERSONAL cap. Remaining must come from the personal count alone. + const quota = memoryQuotaFromRow({ count: 4300, personal_count: 3318, org_count: 982, limit: 5000, plan: 'free' }); + + expect(quota).toEqual({ + plan: 'free', + personal: { count: 3318, limit: 5000, remaining: 1682 }, + org_count: 982, + total: 4300, + }); + }); + + it('never reports negative headroom when a lowered limit sits under the count', () => { + expect(memoryQuotaFromRow({ personal_count: 120, org_count: 0, limit: 100, plan: 'free' }).personal) + .toEqual({ count: 120, limit: 100, remaining: 0 }); + }); + + it('coerces the untyped jsonb fields (numeric strings, missing, junk)', () => { + expect(memoryQuotaFromRow({ personal_count: '7', org_count: null, limit: '10', plan: 'team' })).toEqual({ + plan: 'team', + personal: { count: 7, limit: 10, remaining: 3 }, + org_count: 0, + total: 7, + }); + }); + + it('falls back to the free plan and zeros for an empty row', () => { + expect(memoryQuotaFromRow(null)).toEqual({ + plan: 'free', + personal: { count: 0, limit: 0, remaining: 0 }, + org_count: 0, + total: 0, + }); + expect(memoryQuotaFromRow({ plan: ' ' }).plan).toBe('free'); + }); +}); diff --git a/packages/mcp-core/src/limits/memory-quota.ts b/packages/mcp-core/src/limits/memory-quota.ts new file mode 100644 index 000000000..fe8cf1357 --- /dev/null +++ b/packages/mcp-core/src/limits/memory-quota.ts @@ -0,0 +1,59 @@ +/** + * The caller's memory quota: how many active memories they hold against the + * cap that will refuse their next write, and how many they can still write. + * + * Shapes `lorekit_memory_count` (00047) for `GET /memories/quota`, the + * `memory.quota` MCP tool and `lorekit quota`. Pure and shared (mirrored into + * `supabase/functions/_shared/limits/memory-quota.ts`, guarded by + * `edge-parity.spec.ts`) because the contract decision lives here: the RPC's + * `count` adds personal AND org-owned memories, while its `limit` is the + * PERSONAL cap. The cap trigger (00018) counts them separately — personal rows + * against the personal limit, org rows against each org's own limit — so a + * "remaining" computed from the combined count would be wrong for both. The + * quota therefore reports the personal pair, and the org total beside it. + */ + +/** + * The row `lorekit_memory_count` returns (migration 00047): jsonb, so every + * field arrives untyped and is coerced below rather than trusted. + */ +export interface MemoryCountRow { + count?: unknown; + personal_count?: unknown; + org_count?: unknown; + limit?: unknown; + plan?: unknown; +} + +/** The caller's quota, as `GET /memories/quota` and `memory.quota` answer it. */ +export interface MemoryQuota { + /** Plan name (`free` when the caller has no `user_plans` row). */ + plan: string; + /** Personal memories against the personal cap: the pair the cap trigger enforces. */ + personal: { count: number; limit: number; remaining: number }; + /** Active org-owned memories across the caller's orgs. Each org has its own cap. */ + org_count: number; + /** `personal.count + org_count`: every active memory the caller can see. */ + total: number; +} + +const DEFAULT_PLAN = 'free'; + +function nonNegativeInteger(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +} + +/** Shape a `lorekit_memory_count` row into the quota contract. Total: never throws. */ +export function memoryQuotaFromRow(row: MemoryCountRow | null | undefined): MemoryQuota { + const personalCount = nonNegativeInteger(row?.personal_count); + const orgCount = nonNegativeInteger(row?.org_count); + const limit = nonNegativeInteger(row?.limit); + const plan = typeof row?.plan === 'string' && row.plan.trim() !== '' ? row.plan : DEFAULT_PLAN; + return { + plan, + personal: { count: personalCount, limit, remaining: Math.max(limit - personalCount, 0) }, + org_count: orgCount, + total: personalCount + orgCount, + }; +} diff --git a/packages/mcp-core/src/rest/rest-tool-name.ts b/packages/mcp-core/src/rest/rest-tool-name.ts index dc1dd0015..f764874de 100644 --- a/packages/mcp-core/src/rest/rest-tool-name.ts +++ b/packages/mcp-core/src/rest/rest-tool-name.ts @@ -55,6 +55,7 @@ export const REST_TOOL_NAMES: Readonly> = { 'memories POST /purge': 'memory.purge', 'memories POST /purge-expired': 'memory.purge_expired', 'memories GET /scopes': 'memory.scopes', + 'memories GET /quota': 'memory.quota', 'memories GET /usage': 'memory.usage', 'memories GET /usage/runs': 'memory.usage-runs', 'memories GET /tags': 'memory.tags', diff --git a/packages/mcp-core/src/telemetry/usage-stats.ts b/packages/mcp-core/src/telemetry/usage-stats.ts index e2eb56f01..6cfde0282 100644 --- a/packages/mcp-core/src/telemetry/usage-stats.ts +++ b/packages/mcp-core/src/telemetry/usage-stats.ts @@ -116,6 +116,7 @@ const READ_TOOL_NAMES: ReadonlySet = new Set([ 'memory.list', 'memory.search', 'memory.scopes', + 'memory.quota', 'memory.list_archived', 'memory.usage', 'org.list', diff --git a/packages/schemas/src/domain/memory.ts b/packages/schemas/src/domain/memory.ts index c8429b7e0..bf7fc42b3 100644 --- a/packages/schemas/src/domain/memory.ts +++ b/packages/schemas/src/domain/memory.ts @@ -433,6 +433,31 @@ export type ScopeCount = z.infer; export const ScopesResponseSchema = z.object({ scopes: z.array(ScopeCountSchema) }); export type ScopesResponse = z.infer; +// ── GET /memories/quota ────────────────────────────────────────────────────── + +/** + * `GET /memories/quota` — the caller's active memories against their plan's + * cap. Shaped by `memoryQuotaFromRow` (`packages/mcp-core/src/limits/memory-quota.ts`), + * which explains why it reports the PERSONAL pair rather than the combined + * count: the cap trigger counts personal rows against the personal limit and + * org rows against each org's own limit. + */ +export const MemoryQuotaResponseSchema = z.object({ + /** Plan name; `free` when the caller has no plan row. */ + plan: z.string(), + /** Personal active memories, the personal cap, and the writes left under it. */ + personal: z.object({ + count: z.number().int().nonnegative(), + limit: z.number().int().nonnegative(), + remaining: z.number().int().nonnegative(), + }), + /** Active org-owned memories across the caller's orgs (each org has its own cap). */ + org_count: z.number().int().nonnegative(), + /** `personal.count + org_count` — every active memory the caller can see. */ + total: z.number().int().nonnegative(), +}); +export type MemoryQuotaResponse = z.infer; + // ── GET /memories/tags ─────────────────────────────────────────────────────── /** diff --git a/packages/schemas/src/openapi/spec.spec.ts b/packages/schemas/src/openapi/spec.spec.ts index 6d8198f8e..00beb8812 100644 --- a/packages/schemas/src/openapi/spec.spec.ts +++ b/packages/schemas/src/openapi/spec.spec.ts @@ -30,6 +30,7 @@ describe('generateSpec', () => { '/memories/pivot', '/memories/purge', '/memories/purge-expired', + '/memories/quota', '/memories/read', '/memories/read-activity', '/memories/read-ranking', @@ -70,6 +71,7 @@ describe('generateSpec', () => { 'RestoreResponse', 'PurgeResponse', 'ScopesResponse', + 'MemoryQuotaResponse', 'Org', 'OrgList', 'CreateOrgBody', @@ -156,6 +158,7 @@ describe('generateSpec', () => { ['/memories/purge', 'post'], ['/memories/purge-expired', 'post'], ['/memories/scopes', 'get'], + ['/memories/quota', 'get'], ] as const) { const op = spec.paths[path]![method] as { responses: Record }; expect(op.responses['200']?.content, `${method.toUpperCase()} ${path} has no 200 body`).toBeDefined(); diff --git a/packages/schemas/src/openapi/spec.ts b/packages/schemas/src/openapi/spec.ts index 2dc04e6a4..de26f7429 100644 --- a/packages/schemas/src/openapi/spec.ts +++ b/packages/schemas/src/openapi/spec.ts @@ -23,6 +23,7 @@ import { RestoreResponseSchema, PurgeResponseSchema, ScopesResponseSchema, + MemoryQuotaResponseSchema, ListTagsQuerySchema, TagsResponseSchema, ListFacetsQuerySchema, @@ -132,6 +133,7 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co registry.register('RestoreResponse', RestoreResponseSchema); registry.register('PurgeResponse', PurgeResponseSchema); registry.register('ScopesResponse', ScopesResponseSchema); + registry.register('MemoryQuotaResponse', MemoryQuotaResponseSchema); registry.register('UsageStatsResponse', UsageStatsResponseSchema); registry.register('Org', OrgResponseSchema); registry.register('OrgList', OrgListResponseSchema); @@ -269,6 +271,21 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co 401: errorResponse, 403: errorResponse, }, }); + registry.registerPath({ + method: 'get', path: '/memories/quota', + summary: 'Your active memories against your plan cap, and how many you can still write', tags: ['Memories'], + description: + '`personal` is the pair the memory cap enforces: personal memories against the personal limit, ' + + 'with `remaining` writes left before `POST /memories` is refused with `memory_cap`. Org-owned ' + + 'memories count against each org\'s own cap, so they are reported as `org_count` and are not ' + + 'subtracted from `remaining`. Requires a user-scoped credential; a service-role token has no ' + + 'user and gets 403.', + security, + responses: { + 200: { description: 'Quota', content: { 'application/json': { schema: MemoryQuotaResponseSchema } } }, + 401: errorResponse, 403: errorResponse, + }, + }); registry.registerPath({ method: 'get', path: '/memories/tags', summary: 'List every visible label with how many memories carry it', tags: ['Memories'], diff --git a/packages/schemas/src/shared/tool-catalog.ts b/packages/schemas/src/shared/tool-catalog.ts index 0a9aa1de7..4b8691b10 100644 --- a/packages/schemas/src/shared/tool-catalog.ts +++ b/packages/schemas/src/shared/tool-catalog.ts @@ -502,6 +502,28 @@ export const MCP_TOOLS = [ inputSchema: { type: 'object', properties: {} }, returns: '`{ "scopes": [{ "scope", "count", "last_activity" }] }`, sorted by count desc then scope asc (busiest scope first). `count` is active (non-archived, non-expired) memories; `last_activity` is the newest `created_at` among them, or `null`.', }, + { + name: 'memory.quota', + description: + 'How many active memories you hold against your plan\'s cap, and how many writes are left before ' + + 'memory.write is refused with memory_cap. Takes no arguments. Check it before a bulk write or ' + + 'a migration instead of discovering the cap from a failed write.', + permission: 'read', + annotations: READ_ONLY, + auth: 'token-or-jwt', + surfaces: { + mcp: true, + cli: 'quota', + rest: 'GET /quota', + handler: 'toolQuota', + localMcpExempt: 'server-side plan and memory cap; the offline store has no limit', + }, + inputSchema: { type: 'object', properties: {} }, + returns: + '`{ "plan", "personal": { "count", "limit", "remaining" }, "org_count", "total" }`. `personal` is the ' + + 'pair the cap enforces; org-owned memories count against each org\'s own cap, so `org_count` is ' + + 'reported beside it and not subtracted from `remaining`. A service-role caller has no user and is refused.', + }, { name: 'memory.list_archived', description: 'List archived (soft-deleted) lessons, for one scope or across every scope you can see', diff --git a/packages/web/CLAUDE.md b/packages/web/CLAUDE.md index 3d5bac627..4fe9a95db 100644 --- a/packages/web/CLAUDE.md +++ b/packages/web/CLAUDE.md @@ -104,7 +104,9 @@ until the whole queue drains. `fetchScopes`). If the read still needs server-side code (a surface on the migration backlog), wrap it in a GET route handler under `app/api/**` and read it with `getAppRoute(path, signal)` (`lib/app-route.ts`), as - `/api/plan/usage` and `/api/invites/pending` do. + `/api/invites/pending` does — and treat that route as a stopgap until the + read has its own edge endpoint, the way plan usage moved from + `/api/plan/usage` to `GET /memories/quota`. - **Server actions stay for mutations**, which are one user gesture each, and for server components, where calling the function is a plain call with no queue. diff --git a/packages/web/public/llms.txt b/packages/web/public/llms.txt index 018ca82b7..e8badc34b 100644 --- a/packages/web/public/llms.txt +++ b/packages/web/public/llms.txt @@ -157,6 +157,7 @@ with `npx @lorekit/cli install --force`, which offers to keep, replace, or remov | `memory.search` | ✓ | ✓ | ✗ | | `memory.archive` | ✓ | ✗ | ✓ | | `memory.scopes` | ✓ | ✓ | ✗ | +| `memory.quota` | ✓ | ✓ | ✗ | | `memory.list_archived` | ✓ | ✓ | ✗ | | `memory.restore` | ✓ | ✗ | ✓ | | `memory.purge` | ✓ | ✗ | ✓ | @@ -364,6 +365,20 @@ Returns: `{ "scopes": [{ "scope", "count", "last_activity" }] }`, sorted by coun --- +### memory.quota + +How many active memories you hold against your plan's cap, and how many writes are left before memory.write is refused with memory_cap. Takes no arguments. Check it before a bulk write or a migration instead of discovering the cap from a failed write. + +No arguments. + +Requires **read** permission. + +MCP annotations: read-only, closed-world. + +Returns: `{ "plan", "personal": { "count", "limit", "remaining" }, "org_count", "total" }`. `personal` is the pair the cap enforces; org-owned memories count against each org's own cap, so `org_count` is reported beside it and not subtracted from `remaining`. A service-role caller has no user and is refused. + +--- + ### memory.list_archived List archived (soft-deleted) lessons, for one scope or across every scope you can see. diff --git a/packages/web/src/app/(dashboard)/settings/plan/page.tsx b/packages/web/src/app/(dashboard)/settings/plan/page.tsx index c2177c562..f92b6629b 100644 --- a/packages/web/src/app/(dashboard)/settings/plan/page.tsx +++ b/packages/web/src/app/(dashboard)/settings/plan/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from 'next'; import { CreditCard, ExternalLink } from 'lucide-react'; -import { getPlanUsage } from '@/lib/plan'; +import type { MemoryQuotaResponse } from '@lorekit/schemas/memory'; +import { quotaRequest } from '@/lib/api/memories'; +import { serverAccessToken } from '@/lib/api/session-server'; import { PlanUsageBar } from '@/components/settings/PlanUsageBar'; import { PlanPeakUsage } from '@/components/settings/PlanPeakUsage'; import { SectionPanel } from '@/components/ui/SectionPanel'; @@ -9,8 +11,23 @@ export const metadata: Metadata = { title: 'Plan — Settings' }; const DISCORD_INVITE = 'https://discord.gg/SPa24vGa7R'; +/** + * The caller's quota from `GET /memories/quota`, or `null` when there is no + * session or the request fails — the page renders a skeleton bar rather than + * an error, as it always has. + */ +async function loadQuota(): Promise { + const token = await serverAccessToken(); + if (!token) return null; + try { + return await quotaRequest(token); + } catch { + return null; + } +} + export default async function PlanSettingsPage() { - const usage = await getPlanUsage(); + const quota = await loadQuota(); return ( - {usage ? capitalize(usage.plan) : 'Free'} · Beta + {quota ? capitalize(quota.plan) : 'Free'} · Beta

During beta, LoreKit is completely free. @@ -38,9 +55,9 @@ export default async function PlanSettingsPage() {

Memory usage

- {usage ? ( + {quota ? ( <> - + {/* A live count answers "how full am I now"; this answers "how full WAS I over the last month" — usage_events.memory_count's write-time snapshots (migration 00081). Renders nothing when diff --git a/packages/web/src/app/api/plan/usage/route.ts b/packages/web/src/app/api/plan/usage/route.ts deleted file mode 100644 index 21602c693..000000000 --- a/packages/web/src/app/api/plan/usage/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getPlanUsage } from '@/lib/plan'; - -/** - * GET /api/plan/usage - * - * The caller's plan and active memory count — `getPlanUsage` over HTTP, for the - * top bar's memory badge (`useMemoryTotal`). The badge used to call the server - * action directly. Next's router runs server actions one at a time per client, - * so on `/lore` it led a queue that the pending-invites read and the Explorer's - * list waited behind on every load and every window focus. As a route handler - * it is an ordinary request that runs in parallel and the browser can abort. - * - * Same contract as the action: `null` when there is no session or the RPC - * fails, and the badge degrades to 0. Never cached, because the count moves - * whenever an agent writes. - */ -export const dynamic = 'force-dynamic'; - -export async function GET() { - const usage = await getPlanUsage(); - return NextResponse.json(usage, { headers: { 'Cache-Control': 'no-store' } }); -} diff --git a/packages/web/src/app/api/read-routes.spec.ts b/packages/web/src/app/api/read-routes.spec.ts index 3a3f89513..d238656c1 100644 --- a/packages/web/src/app/api/read-routes.spec.ts +++ b/packages/web/src/app/api/read-routes.spec.ts @@ -1,53 +1,25 @@ /** - * `GET /api/plan/usage` and `GET /api/invites/pending` are transports, not new - * logic: each wraps an existing server function so the browser can read it as - * an ordinary, parallel, abortable request instead of a queued server action. - * Pinned here: they answer exactly what the function returns (including its - * signed-out / failure shape) and are never cached. + * `GET /api/invites/pending` is a transport, not new logic: it wraps the + * existing server function so the browser can read it as an ordinary, parallel, + * abortable request instead of a queued server action. Pinned here: it answers + * exactly what the function returns (including its signed-out / failure shape) + * and is never cached. (Plan usage started here too and has since moved to the + * `GET /memories/quota` edge route.) */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const getPlanUsage = vi.fn(); const listPendingInvitesForMe = vi.fn(); -vi.mock('@/lib/plan', () => ({ getPlanUsage: () => getPlanUsage() })); vi.mock('@/lib/org-invites', () => ({ listPendingInvitesForMe: () => listPendingInvitesForMe(), })); -const planUsageRoute = await import('./plan/usage/route'); const pendingInvitesRoute = await import('./invites/pending/route'); beforeEach(() => { vi.clearAllMocks(); }); - -describe('GET /api/plan/usage', () => { - it('answers the caller plan usage as JSON, uncached', async () => { - getPlanUsage.mockResolvedValue({ count: 3318, limit: 5000, plan: 'free' }); - - const response = await planUsageRoute.GET(); - - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toBe('no-store'); - await expect(response.json()).resolves.toEqual({ count: 3318, limit: 5000, plan: 'free' }); - }); - - it('answers null when there is no session or the RPC failed, as the action did', async () => { - getPlanUsage.mockResolvedValue(null); - - const response = await planUsageRoute.GET(); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toBeNull(); - }); - - it('is rendered per request, never statically', () => { - expect(planUsageRoute.dynamic).toBe('force-dynamic'); - }); -}); - describe('GET /api/invites/pending', () => { it('answers the invites addressed to the caller as JSON, uncached', async () => { const invites = [{ id: 'inv-1', org_id: 'org-1', status: 'pending', org: { name: 'Acme', slug: 'acme' } }]; diff --git a/packages/web/src/components/settings/PlanPeakUsage.tsx b/packages/web/src/components/settings/PlanPeakUsage.tsx index a73da9775..bf1a1b616 100644 --- a/packages/web/src/components/settings/PlanPeakUsage.tsx +++ b/packages/web/src/components/settings/PlanPeakUsage.tsx @@ -4,13 +4,10 @@ * PlanPeakUsage — "peaked at N in the last 30 days", from * `GET /memories/usage`'s `summary.peak_memory_count` (migration 00081). * - * Complements {@link PlanUsageBar}, which shows the LIVE count via the - * pre-existing `lorekit_memory_count()` server action — that answers "how - * full am I right now"; this answers "how full WAS I over the last month", - * from `usage_events.memory_count`'s write-time snapshots. Goes through the - * REST API (`usageRequest`), unlike the legacy `getPlanUsage()` server - * action, which predates the "dashboard is a REST client" rule and is not - * this PR's job to migrate. + * Complements {@link PlanUsageBar}, which shows the LIVE personal count from + * `GET /memories/quota` — that answers "how full am I right now"; this answers + * "how full WAS I over the last month", from `usage_events.memory_count`'s + * write-time snapshots. * * Renders nothing while loading or when the window has no write events — * this is a bonus caption, not a load-bearing figure worth a skeleton. diff --git a/packages/web/src/components/settings/PlanUsageBar.tsx b/packages/web/src/components/settings/PlanUsageBar.tsx index 6c8f3a5a6..01266d0c2 100644 --- a/packages/web/src/components/settings/PlanUsageBar.tsx +++ b/packages/web/src/components/settings/PlanUsageBar.tsx @@ -1,7 +1,13 @@ -import type { PlanUsage } from '@/lib/plan'; +import type { MemoryQuotaResponse } from '@lorekit/schemas/memory'; interface PlanUsageBarProps { - usage: PlanUsage; + /** + * The personal pair from `GET /memories/quota`: personal memories against the + * personal cap. Deliberately not the combined total — org-owned memories + * count against each org's own cap, so adding them here would show a bar + * that fills faster than the cap that will actually refuse a write. + */ + usage: Pick; } /** diff --git a/packages/web/src/content/docs/remote.mdx b/packages/web/src/content/docs/remote.mdx index fa5880671..0cea4121a 100644 --- a/packages/web/src/content/docs/remote.mdx +++ b/packages/web/src/content/docs/remote.mdx @@ -109,7 +109,7 @@ The command checks the connection and the token **before** it writes anything, s -Migrating a large store? The command paces itself under the hosted rate limit and waits out the ones it hits — it says when pacing starts, so a slow run is never a mystery — and it retries a passing server error rather than dropping the memory. A few retries is the budget, though: a memory that keeps failing is reported and the run exits non-zero, and five failures in a row stop it early with a count of what made it. Reaching the memory limit on your plan stops it the same way. In every case the migration is safe to re-run — it picks up where it left off. +Migrating a large store? The command paces itself under the hosted rate limit and waits out the ones it hits — it says when pacing starts, so a slow run is never a mystery — and it retries a passing server error rather than dropping the memory. A few retries is the budget, though: a memory that keeps failing is reported and the run exits non-zero, and five failures in a row stop it early with a count of what made it. Reaching the memory limit on your plan stops it the same way, so check your headroom first with `npx @lorekit/cli quota` — it shows your memories against your plan's cap and how many writes are left (agents can ask the same with the `memory.quota` tool). In every case the migration is safe to re-run — it picks up where it left off. diff --git a/packages/web/src/lib/api/memories.ts b/packages/web/src/lib/api/memories.ts index bd3bbf4b8..92b54d149 100644 --- a/packages/web/src/lib/api/memories.ts +++ b/packages/web/src/lib/api/memories.ts @@ -25,6 +25,7 @@ import type { ListMemoriesQuery, MemoryEntry, MemoryPageResponse, + MemoryQuotaResponse, PurgeResponse, ReadActivityQuery, ReadActivityResponse, @@ -97,6 +98,18 @@ export function getMemoryByRefRequest( ); } +/** + * `GET /memories/quota` — the caller's active memories against their plan's cap. + * `personal` is the pair the cap enforces; `total` adds org-owned memories, + * which count against each org's own cap. + */ +export function quotaRequest(accessToken: string, signal?: AbortSignal): Promise { + return restFetch('/memories/quota', { + accessToken, + ...(signal ? { signal } : {}), + }); +} + export function listScopesRequest(accessToken: string, signal?: AbortSignal): Promise { return restFetch('/memories/scopes', { accessToken, diff --git a/packages/web/src/lib/plan.ts b/packages/web/src/lib/plan.ts deleted file mode 100644 index 4d68eb982..000000000 --- a/packages/web/src/lib/plan.ts +++ /dev/null @@ -1,51 +0,0 @@ -'use server'; - -/** - * Server actions for the /settings/plan page. - * - * Returns the user's active memory count, plan name, and effective limit in - * one round-trip via the lorekit_memory_count() SECURITY DEFINER RPC - * (supabase/migrations/00035_memory_count.sql). - */ - -import { createServerClient } from '@/lib/supabase/server'; -import { getVerifiedUser } from '@/lib/auth/verified-user'; -import { withSpan, logger } from '@/lib/telemetry'; - -export interface PlanUsage { - count: number; - limit: number; - plan: string; -} - -/** - * Fetch the current user's plan + memory usage. - * Returns null on auth failure or RPC error — callers degrade gracefully. - */ -export async function getPlanUsage(): Promise { - return withSpan('lorekit.plan.get_usage', {}, async (span) => { - const supabase = await createServerClient(); - const user = await getVerifiedUser(); - - if (!user) return null; - - const { data, error } = await supabase.rpc('lorekit_memory_count', { - p_user_id: user.id, - }); - - if (error) { - logger.error('lorekit.plan.get_usage.failed', { - 'exception.type': 'SupabaseRpcError', - 'exception.message': error.message, - }); - return null; - } - - const row = data as { count: number; limit: number; plan: string }; - span.setAttribute('lorekit.plan', row.plan); - span.setAttribute('lorekit.memory.count', row.count); - span.setAttribute('lorekit.memory.limit', row.limit); - - return { count: row.count, limit: row.limit, plan: row.plan }; - }); -} diff --git a/packages/web/src/lib/queries/parallel-reads.spec.ts b/packages/web/src/lib/queries/parallel-reads.spec.ts index b7e9a1994..846537b58 100644 --- a/packages/web/src/lib/queries/parallel-reads.spec.ts +++ b/packages/web/src/lib/queries/parallel-reads.spec.ts @@ -27,13 +27,12 @@ interface RecordedOptions { const harness = vi.hoisted(() => ({ recorded: [] as RecordedOptions[] })); -const api = vi.hoisted(() => ({ listMemoriesPostRequest: vi.fn() })); +const api = vi.hoisted(() => ({ listMemoriesPostRequest: vi.fn(), quotaRequest: vi.fn() })); const calledServerAction = vi.hoisted(() => () => { throw new Error('a read went through a server action'); }); -vi.mock('@/lib/plan', () => ({ getPlanUsage: calledServerAction })); vi.mock('@/lib/org-invites', () => ({ listPendingInvitesForMe: calledServerAction })); vi.mock('@/lib/lore', () => ({ archiveLesson: calledServerAction, @@ -47,6 +46,7 @@ vi.mock('@/lib/api/session-browser', () => ({ vi.mock('@/lib/api/memories', async (importOriginal) => ({ ...(await importOriginal()), listMemoriesPostRequest: api.listMemoriesPostRequest, + quotaRequest: api.quotaRequest, })); vi.mock('@tanstack/react-query', async (importOriginal) => { @@ -59,7 +59,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }); const { useMemories } = await import('./lore'); -const { useMemoryTotal, PLAN_USAGE_PATH } = await import('./plan'); +const { useMemoryTotal } = await import('./plan'); const { usePendingInvitesForMe, PENDING_INVITES_PATH } = await import('./pending-invites'); /** The options a hook hands React Query, captured by running it once. */ @@ -130,31 +130,25 @@ describe('useMemories', () => { }); describe('useMemoryTotal', () => { - it('reads the count from GET /api/plan/usage with the query signal', async () => { - fetchMock.mockResolvedValue(jsonResponse({ count: 3318, limit: 5000, plan: 'free' })); + it('reads the total from GET /memories/quota with the query signal', async () => { + api.quotaRequest.mockResolvedValue({ + plan: 'free', + personal: { count: 3318, limit: 5000, remaining: 1682 }, + org_count: 982, + total: 4300, + }); const { signal } = new AbortController(); - await expect(optionsOf(() => useMemoryTotal()).queryFn({ signal })).resolves.toBe(3318); - expect(fetchMock).toHaveBeenCalledWith( - PLAN_USAGE_PATH, - expect.objectContaining({ method: 'GET', cache: 'no-store', signal }), - ); - }); - - it('degrades to 0 when the route answers null (no session, or the RPC failed)', async () => { - fetchMock.mockResolvedValue(jsonResponse(null)); - - await expect( - optionsOf(() => useMemoryTotal()).queryFn({ signal: new AbortController().signal }), - ).resolves.toBe(0); + await expect(optionsOf(() => useMemoryTotal()).queryFn({ signal })).resolves.toBe(4300); + expect(api.quotaRequest).toHaveBeenCalledWith('test-token', signal); }); - it('rejects on a non-2xx answer so React Query can retry it', async () => { - fetchMock.mockResolvedValue(jsonResponse({ error: 'boom' }, 500)); + it('rejects on an API failure so React Query can retry it', async () => { + api.quotaRequest.mockRejectedValue(new Error('upstream 503')); await expect( optionsOf(() => useMemoryTotal()).queryFn({ signal: new AbortController().signal }), - ).rejects.toThrow(`GET ${PLAN_USAGE_PATH} failed with HTTP 500`); + ).rejects.toThrow('upstream 503'); }); }); @@ -175,7 +169,7 @@ describe('usePendingInvitesForMe', () => { }); describe('the paths the hooks read', () => { - it.each([PLAN_USAGE_PATH, PENDING_INVITES_PATH])('%s is served by a GET route handler', (path) => { + it.each([PENDING_INVITES_PATH])('%s is served by a GET route handler', (path) => { const file = fileURLToPath(new URL(`../../app${path}/route.ts`, import.meta.url)); expect(existsSync(file)).toBe(true); diff --git a/packages/web/src/lib/queries/plan.ts b/packages/web/src/lib/queries/plan.ts index 3df7a92e8..e16a9c991 100644 --- a/packages/web/src/lib/queries/plan.ts +++ b/packages/web/src/lib/queries/plan.ts @@ -1,38 +1,34 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import type { PlanUsage } from '@/lib/plan'; -import { getAppRoute } from '@/lib/app-route'; - -/** The route handler (`app/api/plan/usage/route.ts`) the badge reads. */ -export const PLAN_USAGE_PATH = '/api/plan/usage'; +import { browserAccessToken } from '@/lib/api/session-browser'; +import { quotaRequest } from '@/lib/api/memories'; /** - * The caller's total memory count — the SAME figure the /settings/plan page - * shows, because both read the shared `lorekit_memory_count` RPC (personal - * active + org-owned active across the caller's orgs). NOT a paginated list - * length: the header badge used to count `useLoreData().lessons`, which is only - * the first page (capped at the API's 100-row maximum), so it stuck at - * "100 memories" for any larger account. - * - * Caveat: `lorekit_memory_count` has no `expires_at` filter, so it can over-count - * TTL-expired rows relative to the expiry-aware `lorekit_memory_scopes` / - * `lorekit_memory_activity` views the Explorer and heatmap use. + * Every active memory the caller can see — personal plus org-owned — for the + * TopBar badge. The `total` of `GET /memories/quota`, the same answer the + * `memory.quota` MCP tool and `lorekit quota` give. NOT a paginated list length: + * the badge used to count `useLoreData().lessons`, which is only the first page + * (capped at the API's 100-row maximum), so it stuck at "100 memories" for any + * larger account. * - * `getPlanUsage` returns count + limit + plan in one round-trip; the badge needs - * only the count. Degrades to 0 when there is no session / the RPC fails, matching - * the plan page's graceful-null handling. + * A browser REST request with React Query's signal, never a server action: Next + * runs server actions one at a time per client, and this badge used to lead the + * queue that the Explorer's list waited behind on every `/lore` load and window + * focus (`packages/web/CLAUDE.md` → "A client read is never a server action"). * - * Read through `GET /api/plan/usage` rather than by calling the server action: - * Next runs server actions one at a time per client, so this badge used to hold - * the Explorer's list and the invites read behind it on every `/lore` load and - * window focus, and none of them could be cancelled (see `lib/app-route.ts`). + * Caveat: the count has no `expires_at` filter (it matches what the memory cap + * enforces), so it can over-count TTL-expired rows relative to the expiry-aware + * scope counts the Explorer shows. Degrades to 0 with no session. */ export function useMemoryTotal() { return useQuery({ queryKey: ['memory-total'], - queryFn: async ({ signal }) => - (await getAppRoute(PLAN_USAGE_PATH, signal))?.count ?? 0, + queryFn: async ({ signal }) => { + const token = await browserAccessToken(); + if (!token) return 0; + return (await quotaRequest(token, signal)).total; + }, // Changes only when an agent writes — align with the other read-heavy hooks. staleTime: 60_000, }); diff --git a/scripts/smoke/smoke-mcp-tools.mjs b/scripts/smoke/smoke-mcp-tools.mjs index b5f9c0839..580e5501e 100644 --- a/scripts/smoke/smoke-mcp-tools.mjs +++ b/scripts/smoke/smoke-mcp-tools.mjs @@ -711,6 +711,20 @@ check('scopes reports each scope this run wrote, with counts', async () => { } }); +check('quota reports the personal pair against the cap', async () => { + requireUserScoped('a per-user quota'); + const r = await call('memory.quota'); + const q = r.value ?? {}; + ok(typeof q.plan === 'string' && q.plan.length > 0, 'quota has no plan'); + ok(Number.isInteger(q.personal?.count) && q.personal.count >= 0, 'quota has no personal count'); + ok(Number.isInteger(q.personal?.limit) && q.personal.limit > 0, 'quota has no personal limit'); + ok( + q.personal.remaining === Math.max(q.personal.limit - q.personal.count, 0), + `remaining ${q.personal.remaining} is not limit ${q.personal.limit} minus personal count ${q.personal.count}`, + ); + ok(q.total === q.personal.count + q.org_count, 'total is not personal count + org count'); +}); + check('purge and purge_expired answer with a count', async () => { requireUserScoped('an account-wide purge'); const purged = await call('memory.purge', { retention_days: 365 }); diff --git a/supabase/functions/_shared/limits/memory-quota.ts b/supabase/functions/_shared/limits/memory-quota.ts new file mode 100644 index 000000000..4137cd843 --- /dev/null +++ b/supabase/functions/_shared/limits/memory-quota.ts @@ -0,0 +1,59 @@ +/** + * The caller's memory quota: how many active memories they hold against the + * cap that will refuse their next write, and how many they can still write. + * + * Self-contained Deno mirror of `packages/mcp-core/src/limits/memory-quota.ts` — + * the edge tree cannot cross-import the Node package. `edge-parity.spec.ts` + * compares the two with comments stripped, so this file is free to say + * something different here but must stay behaviourally identical below. + * + * Why the personal pair and not the RPC's combined `count`: the cap trigger + * (00018) counts personal rows against the personal limit and org rows against + * each org's own limit, so a "remaining" from the combined count would be wrong + * for both. + */ + +/** + * The row `lorekit_memory_count` returns (migration 00047): jsonb, so every + * field arrives untyped and is coerced below rather than trusted. + */ +export interface MemoryCountRow { + count?: unknown; + personal_count?: unknown; + org_count?: unknown; + limit?: unknown; + plan?: unknown; +} + +/** The caller's quota, as `GET /memories/quota` and `memory.quota` answer it. */ +export interface MemoryQuota { + /** Plan name (`free` when the caller has no `user_plans` row). */ + plan: string; + /** Personal memories against the personal cap: the pair the cap trigger enforces. */ + personal: { count: number; limit: number; remaining: number }; + /** Active org-owned memories across the caller's orgs. Each org has its own cap. */ + org_count: number; + /** `personal.count + org_count`: every active memory the caller can see. */ + total: number; +} + +const DEFAULT_PLAN = 'free'; + +function nonNegativeInteger(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +} + +/** Shape a `lorekit_memory_count` row into the quota contract. Total: never throws. */ +export function memoryQuotaFromRow(row: MemoryCountRow | null | undefined): MemoryQuota { + const personalCount = nonNegativeInteger(row?.personal_count); + const orgCount = nonNegativeInteger(row?.org_count); + const limit = nonNegativeInteger(row?.limit); + const plan = typeof row?.plan === 'string' && row.plan.trim() !== '' ? row.plan : DEFAULT_PLAN; + return { + plan, + personal: { count: personalCount, limit, remaining: Math.max(limit - personalCount, 0) }, + org_count: orgCount, + total: personalCount + orgCount, + }; +} diff --git a/supabase/functions/_shared/rest/rest-tool-name.ts b/supabase/functions/_shared/rest/rest-tool-name.ts index 392508339..d0fb4dd56 100644 --- a/supabase/functions/_shared/rest/rest-tool-name.ts +++ b/supabase/functions/_shared/rest/rest-tool-name.ts @@ -55,6 +55,7 @@ export const REST_TOOL_NAMES: Readonly> = { 'memories POST /purge': 'memory.purge', 'memories POST /purge-expired': 'memory.purge_expired', 'memories GET /scopes': 'memory.scopes', + 'memories GET /quota': 'memory.quota', 'memories GET /usage': 'memory.usage', 'memories GET /usage/runs': 'memory.usage-runs', 'memories GET /tags': 'memory.tags', diff --git a/supabase/functions/_shared/schemas/memory.ts b/supabase/functions/_shared/schemas/memory.ts index 8e802f654..a1c1d48e3 100644 --- a/supabase/functions/_shared/schemas/memory.ts +++ b/supabase/functions/_shared/schemas/memory.ts @@ -438,6 +438,31 @@ export type ScopeCount = z.infer; export const ScopesResponseSchema = z.object({ scopes: z.array(ScopeCountSchema) }); export type ScopesResponse = z.infer; +// ── GET /memories/quota ────────────────────────────────────────────────────── + +/** + * `GET /memories/quota` — the caller's active memories against their plan's + * cap. Shaped by `memoryQuotaFromRow` (`packages/mcp-core/src/limits/memory-quota.ts`), + * which explains why it reports the PERSONAL pair rather than the combined + * count: the cap trigger counts personal rows against the personal limit and + * org rows against each org's own limit. + */ +export const MemoryQuotaResponseSchema = z.object({ + /** Plan name; `free` when the caller has no plan row. */ + plan: z.string(), + /** Personal active memories, the personal cap, and the writes left under it. */ + personal: z.object({ + count: z.number().int().nonnegative(), + limit: z.number().int().nonnegative(), + remaining: z.number().int().nonnegative(), + }), + /** Active org-owned memories across the caller's orgs (each org has its own cap). */ + org_count: z.number().int().nonnegative(), + /** `personal.count + org_count` — every active memory the caller can see. */ + total: z.number().int().nonnegative(), +}); +export type MemoryQuotaResponse = z.infer; + // ── GET /memories/tags ─────────────────────────────────────────────────────── /** diff --git a/supabase/functions/_shared/schemas/openapi/spec.ts b/supabase/functions/_shared/schemas/openapi/spec.ts index e58ccc341..a448aa0bd 100644 --- a/supabase/functions/_shared/schemas/openapi/spec.ts +++ b/supabase/functions/_shared/schemas/openapi/spec.ts @@ -28,6 +28,7 @@ import { RestoreResponseSchema, PurgeResponseSchema, ScopesResponseSchema, + MemoryQuotaResponseSchema, ListTagsQuerySchema, TagsResponseSchema, ListFacetsQuerySchema, @@ -137,6 +138,7 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co registry.register('RestoreResponse', RestoreResponseSchema); registry.register('PurgeResponse', PurgeResponseSchema); registry.register('ScopesResponse', ScopesResponseSchema); + registry.register('MemoryQuotaResponse', MemoryQuotaResponseSchema); registry.register('UsageStatsResponse', UsageStatsResponseSchema); registry.register('Org', OrgResponseSchema); registry.register('OrgList', OrgListResponseSchema); @@ -274,6 +276,21 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co 401: errorResponse, 403: errorResponse, }, }); + registry.registerPath({ + method: 'get', path: '/memories/quota', + summary: 'Your active memories against your plan cap, and how many you can still write', tags: ['Memories'], + description: + '`personal` is the pair the memory cap enforces: personal memories against the personal limit, ' + + 'with `remaining` writes left before `POST /memories` is refused with `memory_cap`. Org-owned ' + + 'memories count against each org\'s own cap, so they are reported as `org_count` and are not ' + + 'subtracted from `remaining`. Requires a user-scoped credential; a service-role token has no ' + + 'user and gets 403.', + security, + responses: { + 200: { description: 'Quota', content: { 'application/json': { schema: MemoryQuotaResponseSchema } } }, + 401: errorResponse, 403: errorResponse, + }, + }); registry.registerPath({ method: 'get', path: '/memories/tags', summary: 'List every visible label with how many memories carry it', tags: ['Memories'], diff --git a/supabase/functions/_shared/schemas/tool-catalog.ts b/supabase/functions/_shared/schemas/tool-catalog.ts index 4e162fc30..d40b3b8e9 100644 --- a/supabase/functions/_shared/schemas/tool-catalog.ts +++ b/supabase/functions/_shared/schemas/tool-catalog.ts @@ -507,6 +507,28 @@ export const MCP_TOOLS = [ inputSchema: { type: 'object', properties: {} }, returns: '`{ "scopes": [{ "scope", "count", "last_activity" }] }`, sorted by count desc then scope asc (busiest scope first). `count` is active (non-archived, non-expired) memories; `last_activity` is the newest `created_at` among them, or `null`.', }, + { + name: 'memory.quota', + description: + 'How many active memories you hold against your plan\'s cap, and how many writes are left before ' + + 'memory.write is refused with memory_cap. Takes no arguments. Check it before a bulk write or ' + + 'a migration instead of discovering the cap from a failed write.', + permission: 'read', + annotations: READ_ONLY, + auth: 'token-or-jwt', + surfaces: { + mcp: true, + cli: 'quota', + rest: 'GET /quota', + handler: 'toolQuota', + localMcpExempt: 'server-side plan and memory cap; the offline store has no limit', + }, + inputSchema: { type: 'object', properties: {} }, + returns: + '`{ "plan", "personal": { "count", "limit", "remaining" }, "org_count", "total" }`. `personal` is the ' + + 'pair the cap enforces; org-owned memories count against each org\'s own cap, so `org_count` is ' + + 'reported beside it and not subtracted from `remaining`. A service-role caller has no user and is refused.', + }, { name: 'memory.list_archived', description: 'List archived (soft-deleted) lessons, for one scope or across every scope you can see', diff --git a/supabase/functions/mcp/permissions.ts b/supabase/functions/mcp/permissions.ts index e24fc0fb5..a1856c572 100644 --- a/supabase/functions/mcp/permissions.ts +++ b/supabase/functions/mcp/permissions.ts @@ -30,6 +30,9 @@ export const READ_TOOLS: ReadonlySet = new Set([ // with records that are not lore. Permission gating and the records-read // metric are different questions about the same family. 'memory.scopes', + // Your own counts against your cap. Read-gated like every other read: a + // write-only token has no business learning the account's size. + 'memory.quota', // `org.list` is a read like any other. It used to be ungated because the org // tools were JWT-only; they now serve `lk_*` tokens, and listing the orgs you // belong to is exactly the shape of thing a read token should be able to do. diff --git a/supabase/functions/mcp/tool-dispatch.generated.ts b/supabase/functions/mcp/tool-dispatch.generated.ts index 8a1cbbb58..9cad21a93 100644 --- a/supabase/functions/mcp/tool-dispatch.generated.ts +++ b/supabase/functions/mcp/tool-dispatch.generated.ts @@ -13,6 +13,7 @@ import { toolSearch, toolArchive, toolScopes, + toolQuota, toolListArchived, toolRestore, toolPurge, @@ -40,6 +41,7 @@ export const MEMORY_TOOLS = { 'memory.search': toolSearch, 'memory.archive': toolArchive, 'memory.scopes': toolScopes, + 'memory.quota': toolQuota, 'memory.list_archived': toolListArchived, 'memory.restore': toolRestore, 'memory.purge': toolPurge, diff --git a/supabase/functions/mcp/tools.ts b/supabase/functions/mcp/tools.ts index ebbcea9f5..b012181dd 100644 --- a/supabase/functions/mcp/tools.ts +++ b/supabase/functions/mcp/tools.ts @@ -28,6 +28,7 @@ import { createTracedClient, type Span } from '../_shared/telemetry/otel.ts'; import { translateCapError } from './limits.ts'; import { translateOrgPermissionError } from './org-permissions.ts'; import { parseCreatedAt } from '../_shared/limits/created-at.ts'; +import { memoryQuotaFromRow, type MemoryCountRow } from '../_shared/limits/memory-quota.ts'; import { parseTtl } from './ttl.ts'; import { parseOrigin } from '../_shared/provenance/origin.ts'; import { recordAuditDeferred } from '../_shared/audit/audit.ts'; @@ -1359,6 +1360,37 @@ export async function toolScopes( return { scopes }; } +/** + * `memory.quota` — the caller's active memories against their plan's cap. + * + * The same RPC and the same shaping as `GET /memories/quota` + * (`memories/handlers/quota.ts`): `lorekit_memory_count` is actor-guarded by + * 00047, and `memoryQuotaFromRow` reports the PERSONAL pair the cap trigger + * enforces rather than the RPC's combined count. Counts only, so a scoped key + * may call it — there is no scope or key name to narrow. + */ +export async function toolQuota( + db: DbClient, + _params: Params, + userId: string | null, + span: Span, + _keyScoping?: KeyRestriction, +) { + if (!userId) throw new UserInputError('memory.quota requires a user-scoped credential (service-role tokens have no user and no cap)'); + + const tracedDb = createTracedClient(db, span); + const { data, error } = await tracedDb.rpc('lorekit_memory_count', { p_user_id: userId }); + if (error) throw new Error((error as { message: string }).message); + + const quota = memoryQuotaFromRow(data); + span.setAttributes({ + 'lorekit.plan': quota.plan, + 'lorekit.memory.count': quota.personal.count, + 'lorekit.memory.limit': quota.personal.limit, + }); + return quota; +} + // ═══════════════════════════════════════════════════════════════════════════ // Retention policies ("grooming") — policy.*, groom.*, memory.protect. // diff --git a/supabase/functions/memories/handlers/quota.ts b/supabase/functions/memories/handlers/quota.ts new file mode 100644 index 000000000..c55d47844 --- /dev/null +++ b/supabase/functions/memories/handlers/quota.ts @@ -0,0 +1,45 @@ +import type { AuthContext } from '../../_shared/api/auth.ts'; +import type { DbClient } from '../../_shared/api/auth.ts'; +import { ok, forbidden } from '../../_shared/api/respond.ts'; +import { createTracedClient } from '../../_shared/telemetry/otel.ts'; +import type { Span } from '../../_shared/telemetry/otel.ts'; +import { memoryQuotaFromRow, type MemoryCountRow } from '../../_shared/limits/memory-quota.ts'; + +/** + * GET /memories/quota — the caller's active memories against their plan's cap, + * and how many writes are left before `POST /memories` is refused with + * `memory_cap`. The REST twin of the `memory.quota` MCP tool and `lorekit quota`. + * + * The count lives in Postgres (`lorekit_memory_count`, actor-guarded by 00047: + * a caller-supplied user id is honoured only on a service-role connection), so + * this handler only shapes it — see `memoryQuotaFromRow` for why the answer is + * the PERSONAL pair rather than the RPC's combined count. + * + * Counts only, never a scope or key name, so a scope-restricted key may read it: + * there is nothing for `p_key_scopes` to narrow (the same reason `/usage` is not + * name-bearing). A service-role caller has no user whose cap this could be, so + * it is refused rather than answered with zeros that look like an empty account. + */ +export async function handleQuota( + _req: Request, auth: AuthContext, db: DbClient, span: Span, + _params: Record, cors: Record, +): Promise { + span.setAttributes({ 'lorekit.operation': 'memories.quota' }); + if (!auth.userId) { + return forbidden('Quota requires a user-scoped credential (service-role tokens have no user and no cap)', cors); + } + + const tracedDb = createTracedClient(db, span); + const { data, error } = await tracedDb.rpc('lorekit_memory_count', { + p_user_id: auth.userId, + }); + if (error) { span.error(`DB: ${error.message}`); throw error; } + + const quota = memoryQuotaFromRow(data); + span.setAttributes({ + 'lorekit.plan': quota.plan, + 'lorekit.memory.count': quota.personal.count, + 'lorekit.memory.limit': quota.personal.limit, + }); + return ok(quota, cors); +} diff --git a/supabase/functions/memories/index.ts b/supabase/functions/memories/index.ts index 47580c56b..e4e1b3fb6 100644 --- a/supabase/functions/memories/index.ts +++ b/supabase/functions/memories/index.ts @@ -15,6 +15,7 @@ import { handleSearch } from './handlers/search.ts'; import { handleRestore } from './handlers/restore.ts'; import { handlePurge, handlePurgeExpired } from './handlers/purge.ts'; import { handleScopes } from './handlers/scopes.ts'; +import { handleQuota } from './handlers/quota.ts'; import { handleUsage } from './handlers/usage.ts'; import { handleUsageRuns } from './handlers/usage-runs.ts'; import { handleTags } from './handlers/tags.ts'; @@ -72,6 +73,7 @@ const router = createRouter([ { method: 'POST', path: '/purge', handler: handlePurge, requires: 'write' }, { method: 'POST', path: '/purge-expired', handler: handlePurgeExpired, requires: 'write' }, { method: 'GET', path: '/scopes', handler: handleScopes, requires: 'read' }, + { method: 'GET', path: '/quota', handler: handleQuota, requires: 'read' }, { method: 'GET', path: '/usage', handler: handleUsage, requires: 'read' }, { method: 'GET', path: '/usage/runs', handler: handleUsageRuns, requires: 'read' }, { method: 'GET', path: '/tags', handler: handleTags, requires: 'read' }, From 01cf83ad7d4930dd2c8daf01c03d1b720b745a81 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 19:15:57 +0000 Subject: [PATCH 04/13] test: pin memory.quota in the permission, local-MCP and usage-stats guards; cover lorekit quota Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/cli/test/quota.test.mjs | 126 ++++++++++++++++++ .../mcp-core/src/auth/permissions.spec.ts | 4 +- .../src/mcp-guards/surface-parity.spec.ts | 1 + .../_shared/telemetry/usage-stats.ts | 1 + 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/quota.test.mjs diff --git a/packages/cli/test/quota.test.mjs b/packages/cli/test/quota.test.mjs new file mode 100644 index 000000000..c18c25f32 --- /dev/null +++ b/packages/cli/test/quota.test.mjs @@ -0,0 +1,126 @@ +// `lorekit quota` — the CLI face of GET /memories/quota. Remote-only, and the +// one read command that exits 1 when the remote cannot answer, because there +// is no offline answer to fall back to. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import http from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const BIN = fileURLToPath(new URL('../bin/lorekit.mjs', import.meta.url)); +const tmp = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + +const QUOTA = { + plan: 'free', + personal: { count: 4600, limit: 5000, remaining: 400 }, + org_count: 982, + total: 5582, +}; + +function baseEnv(home, extraEnv = {}) { + const env = { + ...process.env, + NO_COLOR: '1', + HOME: home, + USERPROFILE: home, + LOREKIT_HOME: home, + LOREKIT_TELEMETRY: '0', + }; + delete env.LOREKIT_TOKEN; + delete env.LOREKIT_MCP_URL; + delete env.LOREKIT_ENDPOINT; + return Object.assign(env, extraEnv); +} + +function runQuotaAsync(args, extraEnv) { + const root = tmp('lk-quota-root-'); + const home = tmp('lk-quota-home-'); + return new Promise((resolve) => { + const child = spawn(process.execPath, [BIN, 'quota', ...args, '--dir', root], { + env: baseEnv(home, extraEnv), + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d)); + child.stderr.on('data', (d) => (stderr += d)); + child.on('close', (status) => resolve({ status, stdout, stderr })); + }); +} + +// `LOREKIT_MCP_URL` points at `/mcp`, from which the REST base is derived, so +// the command lands on `GET /memories/quota`. +async function withMockRemote(handler, fn) { + const seen = []; + const server = http.createServer((req, res) => { + seen.push(`${req.method} ${req.url}`); + handler(req, res); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + try { + return await fn({ + seen, + env: { LOREKIT_MCP_URL: `http://127.0.0.1:${port}/mcp`, LOREKIT_TOKEN: 'lk_ro_test' }, + }); + } finally { + server.close(); + } +} + +const answerWith = (body, status = 200) => (_req, res) => { + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(body)); +}; + +test('quota --json reports the remote quota from GET /memories/quota', async () => { + await withMockRemote(answerWith(QUOTA), async ({ seen, env }) => { + const res = await runQuotaAsync(['--json'], env); + assert.equal(res.status, 0, res.stderr); + assert.ok(seen.some((r) => /^GET \/+memories\/quota$/.test(r)), `requests seen: ${seen.join(', ')}`); + const out = JSON.parse(res.stdout); + assert.equal(out.available, true); + assert.deepEqual( + { plan: out.plan, personal: out.personal, org_count: out.org_count, total: out.total }, + QUOTA, + ); + }); +}); + +test('quota prints the personal pair, the org count beside it, and warns near the cap', async () => { + await withMockRemote(answerWith(QUOTA), async ({ env }) => { + const res = await runQuotaAsync([], env); + assert.equal(res.status, 0, res.stderr); + const text = res.stdout + res.stderr; + assert.match(text, /4600 \/ 5000/); + assert.match(text, /400 left/); + assert.match(text, /982/); + assert.match(text, /near the cap/); + }); +}); + +test('quota exits 1 with an accurate note when the remote fails', async () => { + await withMockRemote(answerWith({ error: 'kaboom' }, 500), async ({ env }) => { + const res = await runQuotaAsync(['--json'], env); + assert.equal(res.status, 1); + const out = JSON.parse(res.stdout); + assert.equal(out.available, false); + assert.match(out.reason, /500|kaboom/); + }); +}); + +test('quota exits 1 when no remote is configured', () => { + const root = tmp('lk-quota-root-'); + const home = tmp('lk-quota-home-'); + const res = spawnSync(process.execPath, [BIN, 'quota', '--json', '--dir', root], { + encoding: 'utf8', + env: baseEnv(home), + }); + assert.equal(res.status, 1, res.stderr); + const out = JSON.parse(res.stdout); + assert.equal(out.available, false); + assert.match(out.reason, /endpoint|token/); +}); diff --git a/packages/mcp-core/src/auth/permissions.spec.ts b/packages/mcp-core/src/auth/permissions.spec.ts index cf01ab321..98b3c55dd 100644 --- a/packages/mcp-core/src/auth/permissions.spec.ts +++ b/packages/mcp-core/src/auth/permissions.spec.ts @@ -54,9 +54,11 @@ describe('READ_TOOLS / WRITE_TOOLS', () => { // project names — it is not exempt merely because it names no scope. // `org.list` joined the read family when the org tools stopped being // JWT-only: listing the orgs you belong to is exactly what a read token - // should be able to do. + // should be able to do. `memory.quota` is your own counts against your + // cap: read-gated, so a write-only token cannot learn the account's size. [ 'memory.list', 'memory.list_archived', 'memory.read', 'memory.scopes', 'memory.search', 'org.list', + 'memory.quota', // Retention policies ("grooming") — listing saved rules and previewing // what one would archive are both reads, same family as memory.list_archived. 'policy.list', 'groom.preview', diff --git a/packages/mcp-core/src/mcp-guards/surface-parity.spec.ts b/packages/mcp-core/src/mcp-guards/surface-parity.spec.ts index c6d10f660..b83f6c985 100644 --- a/packages/mcp-core/src/mcp-guards/surface-parity.spec.ts +++ b/packages/mcp-core/src/mcp-guards/surface-parity.spec.ts @@ -89,6 +89,7 @@ describe('every op declares its surfaces', () => { 'memory.protect', 'memory.purge', 'memory.purge_expired', + 'memory.quota', 'policy.create', 'policy.delete', 'policy.list', diff --git a/supabase/functions/_shared/telemetry/usage-stats.ts b/supabase/functions/_shared/telemetry/usage-stats.ts index e2eb56f01..6cfde0282 100644 --- a/supabase/functions/_shared/telemetry/usage-stats.ts +++ b/supabase/functions/_shared/telemetry/usage-stats.ts @@ -116,6 +116,7 @@ const READ_TOOL_NAMES: ReadonlySet = new Set([ 'memory.list', 'memory.search', 'memory.scopes', + 'memory.quota', 'memory.list_archived', 'memory.usage', 'org.list', From 79c8ba098250cc9a4a25e77c340e75d5cca7d7f9 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Wed, 21 Oct 2026 08:08:51 +0000 Subject: [PATCH 05/13] address review comment: badge comment no longer claims it matches the plan page Addresses the pr-reviewer comment: https://github.com/mthines/lorekit/pull/713#discussion_r4334244351 Refs: https://github.com/mthines/lorekit/pull/713 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/web/src/components/lore/MemoryExpandButton.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web/src/components/lore/MemoryExpandButton.tsx b/packages/web/src/components/lore/MemoryExpandButton.tsx index 9b7caad74..438005110 100644 --- a/packages/web/src/components/lore/MemoryExpandButton.tsx +++ b/packages/web/src/components/lore/MemoryExpandButton.tsx @@ -83,9 +83,9 @@ export function MemoryExpandButton({ // The badge count must NOT come from `data.lessons` — that is only the first // page of the list (capped at the API's 100-row maximum), so counting it stuck - // the badge at "100 memories" for any larger account. The unscoped total is the - // SAME figure the /settings/plan page shows (`useMemoryTotal` → the shared - // `lorekit_memory_count` RPC), so the two never disagree. A `scope`-filtered + // the badge at "100 memories" for any larger account. The unscoped total is + // personal + org (`useMemoryTotal` → `total` of GET /memories/quota), while + // /settings/plan shows only the personal pair against the cap. A `scope`-filtered // instance falls back to the exact per-scope aggregate from GET /memories/scopes. const total = useMemo(() => { if (scope) return data?.scopes.find((s) => s.scope === scope)?.count ?? 0; From cc1844206391f70b065f39a2fa777e0b714d8e2d Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Wed, 21 Oct 2026 08:09:27 +0000 Subject: [PATCH 06/13] address review comment: list quota in the MCP, CLI and route references Addresses the pr-reviewer comment: https://github.com/mthines/lorekit/pull/713#discussion_r4334244358 Refs: https://github.com/mthines/lorekit/pull/713 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- CLAUDE.md | 2 +- docs/mcp-tools.md | 33 ++++++++++++++++++++++++++- packages/cli/README.md | 15 ++++++++++++ supabase/functions/memories/CLAUDE.md | 1 + 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d32c458db..29b9b7301 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ lets humans browse, search, and manage those lessons. | `@lorekit/core` | `packages/mcp-core/` | Scope validator, DB client, 10 tool handlers, OTel tracer/meter | | `@lorekit/feature-flags` | `packages/feature-flags/` | OpenFeature-standard flag evaluation. `registry.ts` is the single hand-authored source of truth (zod-validated, all four OpenFeature value types); `nx run feature-flags:generate` projects it into typed TS bindings + a language-neutral `flags.manifest.json`. `LoreKitFlagProvider` resolves: session override → A/B(/n) experiment (deterministic FNV-1a bucketing on a stable `targetingKey`) → static default. Telemetry is two mechanisms: `featureFlagOtelHook` stamps `feature_flag.*` span attributes per evaluation (server-side), while `packages/web`'s `FeatureFlagsProvider` tags RUM signals with `feature_flag.` (browser-side). UI-affecting experiments use a copy-and-suffix component convention (a resolver + one whole component per variant, never inline branches). `/settings/developer` (override UI) is open to any signed-in user outside production; in production it's gated by a server-side email allowlist (`developer-users.ts`, `notFound()` on the page). Package rules + file map: [`packages/feature-flags/CLAUDE.md`](./packages/feature-flags/CLAUDE.md). Full guide: [`docs/feature-flags.md`](./docs/feature-flags.md). Add/update/remove a flag via the `feature-flags` skill | | `@lorekit/web` | `packages/web/` | Next.js 15 dashboard (Vercel) | -| `@lorekit/cli` | `packages/cli/` | Zero-dep Node CLI. **Setup:** `install`/`uninstall`/`doctor`/`update` (scaffold skills + MCP + lifecycle hooks into `.claude`; connectivity/token/scope health checks; offline skill refresh). **Reads:** `list`/`search`/`show`/`stats`/`scopes`/`diff`/`tree`/`lint`/`dedupe`/`link` (Offline + Remote split, `--json`/`--scope`). **Recurrence tooling:** `obligations` (a changed-file set vs a declarative Surface-Partner Map; per-entry `state` `advisory`/`gating`/`retired`) + `invariants candidates` (candidate scan reusing `dedupe`'s clustering; never auto-compiles or gates). **Maintenance:** `purge`/`purge-expired` (remote-only, account-wide, irreversible, confirm-or-`--yes`). Plus `hook` (shared hook engine behind the plugins), `mcp` (local stdio MCP server), `migrate`, `completion`. Self-contained OTLP telemetry (`service.name=cli`). Full command reference: [`docs/cli.md`](./docs/cli.md) | +| `@lorekit/cli` | `packages/cli/` | Zero-dep Node CLI. **Setup:** `install`/`uninstall`/`doctor`/`update` (scaffold skills + MCP + lifecycle hooks into `.claude`; connectivity/token/scope health checks; offline skill refresh). **Reads:** `list`/`search`/`show`/`stats`/`scopes`/`quota`/`diff`/`tree`/`lint`/`dedupe`/`link` (Offline + Remote split, `--json`/`--scope`). **Recurrence tooling:** `obligations` (a changed-file set vs a declarative Surface-Partner Map; per-entry `state` `advisory`/`gating`/`retired`) + `invariants candidates` (candidate scan reusing `dedupe`'s clustering; never auto-compiles or gates). **Maintenance:** `purge`/`purge-expired` (remote-only, account-wide, irreversible, confirm-or-`--yes`). Plus `hook` (shared hook engine behind the plugins), `mcp` (local stdio MCP server), `migrate`, `completion`. Self-contained OTLP telemetry (`service.name=cli`). Full command reference: [`docs/cli.md`](./docs/cli.md) | | `plugins/` | `plugins/` | Per-framework deterministic bundles: `lorekit-claude` (marketplace plugin: skill + hooks + MCP), `lorekit-cursor` (rule + `stop` hook), `lorekit-codex` (feature-flagged hooks + `AGENTS.md` fallback, experimental). Root `.claude-plugin/marketplace.json` lists the Claude plugin. | | `supabase` | `supabase/` | Edge Functions (production MCP server), migrations, NX targets | diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 2b947a267..2600d76be 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -44,7 +44,7 @@ used to show up as write + destructive in MCP clients. | Class | Tools | |-------|-------| -| Read-only | `memory.read`, `memory.list`, `memory.search`, `memory.scopes`, `memory.list_archived`, `org.list`, `policy.list`, `groom.preview` | +| Read-only | `memory.read`, `memory.list`, `memory.search`, `memory.scopes`, `memory.quota`, `memory.list_archived`, `org.list`, `policy.list`, `groom.preview` | | Write, non-destructive | `memory.archive`, `memory.restore`, `memory.protect`, `groom.run`, `org.create`, `policy.create` | | Write, destructive | `memory.write`, `memory.delete`, `memory.purge`, `memory.purge_expired`, `org.rename`, `org.delete`, `policy.update`, `policy.delete` | @@ -526,6 +526,37 @@ so whole scopes go missing for exactly the accounts with the most lore. --- +## memory.quota + +How many active memories you hold against your plan's cap, and how many writes are left before +`memory.write` is refused with `memory_cap`. Takes no arguments. Requires a token with read +permission (`lk_rw_*` or `lk_ro_*`); a service-role token has no user and is refused. + +`personal` is the pair the cap enforces: your personal memories against your personal limit, with +`remaining` = limit − count (never negative). Org-owned memories count against each org's own cap, +so they are reported as `org_count` beside it and never subtracted. It is the same answer +`GET /memories/quota` and the `lorekit quota` command return. + +```json +{ + "params": { + "name": "memory.quota", + "arguments": {} + } +} +``` + +```json +{ + "plan": "free", + "personal": { "count": 3318, "limit": 5000, "remaining": 1682 }, + "org_count": 982, + "total": 4300 +} +``` + +--- + ## memory.list_archived List soft-archived lessons for a scope, newest archived first. Requires a token with read permission (`lk_rw_*` or `lk_ro_*`). `scope` is **optional** — omit it to list the archive across every scope you can see. diff --git a/packages/cli/README.md b/packages/cli/README.md index 7dd4d69d5..bf5da7b90 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -408,6 +408,21 @@ Offline one. A denied, unconfigured, unreachable, or erroring remote degrades to a short, accurate note (network error / HTTP status — never a faked listing) at exit 0. `--endpoint` / `--token` / `--store` behave as in `list`. +### `lorekit quota` + +Your active memories against your plan's cap and how many writes are left — +remote only, since the offline store has no plan and no cap. + +```bash +lorekit quota +lorekit quota --json # { available, endpoint, plan, personal, org_count, total } +``` + +`personal` is the pair the cap enforces; org-owned memories count against each +org's own cap and are shown beside it, never subtracted. Exits 1 when the remote +is denied, unconfigured, unreachable or erroring, so a script can gate a bulk +write on it. + ### `lorekit diff` Compare the **offline** and **remote** stores for the applicable scopes and diff --git a/supabase/functions/memories/CLAUDE.md b/supabase/functions/memories/CLAUDE.md index 9835d9052..9dd128adb 100644 --- a/supabase/functions/memories/CLAUDE.md +++ b/supabase/functions/memories/CLAUDE.md @@ -19,6 +19,7 @@ Handles all memory operations via HTTP. Auth is managed by the shared `resolveRe | POST | /purge | purge.ts | write | | POST | /purge-expired | purge.ts | write | | GET | /scopes | scopes.ts | read | +| GET | /quota | quota.ts | read | | GET | /tags | tags.ts | read | | GET | /facets | facets.ts | read | | GET | /activity | activity.ts | read | From f457875202bcac6f5ecad851c14067cff98a93d8 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Wed, 21 Oct 2026 08:09:49 +0000 Subject: [PATCH 07/13] address review comment: log a failed quota read on the plan page Addresses the pr-reviewer comment: https://github.com/mthines/lorekit/pull/713#issuecomment-6310777922 Refs: https://github.com/mthines/lorekit/pull/713 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../web/src/app/(dashboard)/settings/plan/page.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/web/src/app/(dashboard)/settings/plan/page.tsx b/packages/web/src/app/(dashboard)/settings/plan/page.tsx index f92b6629b..bcb5eefc3 100644 --- a/packages/web/src/app/(dashboard)/settings/plan/page.tsx +++ b/packages/web/src/app/(dashboard)/settings/plan/page.tsx @@ -3,6 +3,7 @@ import { CreditCard, ExternalLink } from 'lucide-react'; import type { MemoryQuotaResponse } from '@lorekit/schemas/memory'; import { quotaRequest } from '@/lib/api/memories'; import { serverAccessToken } from '@/lib/api/session-server'; +import { logger } from '@/lib/telemetry'; import { PlanUsageBar } from '@/components/settings/PlanUsageBar'; import { PlanPeakUsage } from '@/components/settings/PlanPeakUsage'; import { SectionPanel } from '@/components/ui/SectionPanel'; @@ -14,14 +15,19 @@ const DISCORD_INVITE = 'https://discord.gg/SPa24vGa7R'; /** * The caller's quota from `GET /memories/quota`, or `null` when there is no * session or the request fails — the page renders a skeleton bar rather than - * an error, as it always has. + * an error, as it always has. A failed request is logged + * (`lorekit.plan.quota_failed`) so the skeleton is never the only trace of it. */ async function loadQuota(): Promise { const token = await serverAccessToken(); if (!token) return null; try { return await quotaRequest(token); - } catch { + } catch (err) { + logger.error('lorekit.plan.quota_failed', { + 'exception.type': err instanceof Error ? err.name : 'Error', + 'exception.message': err instanceof Error ? err.message : String(err), + }); return null; } } From 11f7205a8d5fa4168df78e03f593f72d6708e770 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Wed, 21 Oct 2026 08:10:17 +0000 Subject: [PATCH 08/13] address review comment: app-route header no longer claims plan usage is backlog Addresses the pr-reviewer comment: https://github.com/mthines/lorekit/pull/712#discussion_r4334231096 Refs: https://github.com/mthines/lorekit/pull/713 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/web/src/lib/app-route.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/web/src/lib/app-route.ts b/packages/web/src/lib/app-route.ts index dae9df9ff..cb7ee4c06 100644 --- a/packages/web/src/lib/app-route.ts +++ b/packages/web/src/lib/app-route.ts @@ -3,13 +3,14 @@ * read as JSON. * * This exists for the reads that still need server-side code: there is no REST - * endpoint for them yet (`packages/web/CLAUDE.md` lists invites and plan usage - * as migration backlog). They used to be called as server actions straight from - * React Query, and Next's app router runs server actions one at a time per - * client. Every read queued behind whichever action was already in flight, and - * none of them could be aborted, so the header's activity bar stayed lit until - * the whole chain had drained. A route handler is an ordinary request: it runs - * alongside the others and takes React Query's AbortSignal. + * endpoint for them yet (`packages/web/CLAUDE.md` lists invites as migration + * backlog; plan usage has since moved to `GET /memories/quota`). They used to + * be called as server actions straight from React Query, and Next's app router + * runs server actions one at a time per client. Every read queued behind + * whichever action was already in flight, and none of them could be aborted, so + * the header's activity bar stayed lit until the whole chain had drained. A + * route handler is an ordinary request: it runs alongside the others and takes + * React Query's AbortSignal. * * Same-origin, so the session cookie authenticates it; no token is passed. * `no-store` because these answer "what is true now": a refetch on window focus From 1960951427e42aaf1dce745fe7238c458e8aada0 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Wed, 21 Oct 2026 08:13:33 +0000 Subject: [PATCH 09/13] chore: simplify pass (mechanical refactors) Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/cli/src/commands/quota.mjs | 17 +++-------------- packages/cli/src/store/remote.mjs | 8 ++------ packages/mcp-core/src/limits/memory-quota.ts | 15 +++------------ packages/schemas/src/domain/memory.ts | 7 ++----- .../src/components/settings/PlanUsageBar.tsx | 7 +------ packages/web/src/lib/queries/plan.ts | 18 +++--------------- .../functions/_shared/limits/memory-quota.ts | 14 ++------------ supabase/functions/_shared/schemas/memory.ts | 7 ++----- supabase/functions/mcp/tools.ts | 7 +------ supabase/functions/memories/handlers/quota.ts | 16 +++------------- 10 files changed, 22 insertions(+), 94 deletions(-) diff --git a/packages/cli/src/commands/quota.mjs b/packages/cli/src/commands/quota.mjs index 91650d336..2c0ba866d 100644 --- a/packages/cli/src/commands/quota.mjs +++ b/packages/cli/src/commands/quota.mjs @@ -1,17 +1,6 @@ -// `lorekit quota` — how many active memories you hold against your plan's cap, -// and how many writes are left before the remote refuses one with `memory_cap`. -// The CLI face of `GET /memories/quota` and the `memory.quota` MCP tool. -// -// Remote-only, unlike the other read commands: the offline store has no plan -// and no cap, so there is no Offline section to render. `personal` is the pair -// the cap enforces; org-owned memories count against each org's own cap, so -// they are shown beside it and never subtracted from what is left. -// -// Exits 1 when the remote cannot answer (denied, unconfigured, unreachable, or -// erroring): a script that gates a bulk write on this needs a failure it can -// see, and there is no partial answer to fall back to. `--json` emits -// `{ available, endpoint, plan, personal, org_count, total }` or -// `{ available: false, reason }`. +// `lorekit quota` — your memories against your plan's cap (GET /memories/quota). +// Remote-only, and exits 1 when the remote cannot answer: a script gating a bulk +// write needs to see that failure, and there is no offline answer to fall back to. import process from 'node:process'; import { resolveProjectRoot } from '../shared/config.mjs'; import { resolveDenies } from '../shared/control.mjs'; diff --git a/packages/cli/src/store/remote.mjs b/packages/cli/src/store/remote.mjs index 829b1b31a..4291943e0 100644 --- a/packages/cli/src/store/remote.mjs +++ b/packages/cli/src/store/remote.mjs @@ -786,12 +786,8 @@ class RemoteStore { }; } - // The caller's memory quota — `GET /memories/quota` (the REST twin of the - // `memory.quota` MCP tool): `{ plan, personal: { count, limit, remaining }, - // org_count, total }`. `personal` is the pair the cap trigger enforces; org - // memories count against each org's own cap, which is why they sit beside it. - // Remote-only by nature: the offline store has no plan and no cap. Failures - // use this store's standard `{ ok:false, error, networkError }` envelope. + // `GET /memories/quota`, the REST twin of `memory.quota`. Remote-only: the + // offline store has no plan and no cap. Failures use the standard envelope. async quota() { const res = await this._rest('/memories/quota'); if (!res.ok) { diff --git a/packages/mcp-core/src/limits/memory-quota.ts b/packages/mcp-core/src/limits/memory-quota.ts index fe8cf1357..7ebc46097 100644 --- a/packages/mcp-core/src/limits/memory-quota.ts +++ b/packages/mcp-core/src/limits/memory-quota.ts @@ -1,16 +1,7 @@ /** - * The caller's memory quota: how many active memories they hold against the - * cap that will refuse their next write, and how many they can still write. - * - * Shapes `lorekit_memory_count` (00047) for `GET /memories/quota`, the - * `memory.quota` MCP tool and `lorekit quota`. Pure and shared (mirrored into - * `supabase/functions/_shared/limits/memory-quota.ts`, guarded by - * `edge-parity.spec.ts`) because the contract decision lives here: the RPC's - * `count` adds personal AND org-owned memories, while its `limit` is the - * PERSONAL cap. The cap trigger (00018) counts them separately — personal rows - * against the personal limit, org rows against each org's own limit — so a - * "remaining" computed from the combined count would be wrong for both. The - * quota therefore reports the personal pair, and the org total beside it. + * The caller's memory quota, shaped from `lorekit_memory_count` (00047); mirrored to `_shared/limits/`. + * Reports the PERSONAL pair because the cap trigger (00018) counts personal and org rows against + * separate limits, so a "remaining" from the RPC's combined `count` would be wrong for both. */ /** diff --git a/packages/schemas/src/domain/memory.ts b/packages/schemas/src/domain/memory.ts index 42612c9f9..855e02f5b 100644 --- a/packages/schemas/src/domain/memory.ts +++ b/packages/schemas/src/domain/memory.ts @@ -446,11 +446,8 @@ export type ScopesResponse = z.infer; // ── GET /memories/quota ────────────────────────────────────────────────────── /** - * `GET /memories/quota` — the caller's active memories against their plan's - * cap. Shaped by `memoryQuotaFromRow` (`packages/mcp-core/src/limits/memory-quota.ts`), - * which explains why it reports the PERSONAL pair rather than the combined - * count: the cap trigger counts personal rows against the personal limit and - * org rows against each org's own limit. + * `GET /memories/quota` — the caller's active memories against their plan's cap, + * shaped by `memoryQuotaFromRow` (`packages/mcp-core/src/limits/memory-quota.ts`). */ export const MemoryQuotaResponseSchema = z.object({ /** Plan name; `free` when the caller has no plan row. */ diff --git a/packages/web/src/components/settings/PlanUsageBar.tsx b/packages/web/src/components/settings/PlanUsageBar.tsx index 01266d0c2..7133ecda5 100644 --- a/packages/web/src/components/settings/PlanUsageBar.tsx +++ b/packages/web/src/components/settings/PlanUsageBar.tsx @@ -1,12 +1,7 @@ import type { MemoryQuotaResponse } from '@lorekit/schemas/memory'; interface PlanUsageBarProps { - /** - * The personal pair from `GET /memories/quota`: personal memories against the - * personal cap. Deliberately not the combined total — org-owned memories - * count against each org's own cap, so adding them here would show a bar - * that fills faster than the cap that will actually refuse a write. - */ + /** The personal pair from `GET /memories/quota` — not the total, since org memories have their own caps. */ usage: Pick; } diff --git a/packages/web/src/lib/queries/plan.ts b/packages/web/src/lib/queries/plan.ts index e16a9c991..b0aa816f3 100644 --- a/packages/web/src/lib/queries/plan.ts +++ b/packages/web/src/lib/queries/plan.ts @@ -5,21 +5,9 @@ import { browserAccessToken } from '@/lib/api/session-browser'; import { quotaRequest } from '@/lib/api/memories'; /** - * Every active memory the caller can see — personal plus org-owned — for the - * TopBar badge. The `total` of `GET /memories/quota`, the same answer the - * `memory.quota` MCP tool and `lorekit quota` give. NOT a paginated list length: - * the badge used to count `useLoreData().lessons`, which is only the first page - * (capped at the API's 100-row maximum), so it stuck at "100 memories" for any - * larger account. - * - * A browser REST request with React Query's signal, never a server action: Next - * runs server actions one at a time per client, and this badge used to lead the - * queue that the Explorer's list waited behind on every `/lore` load and window - * focus (`packages/web/CLAUDE.md` → "A client read is never a server action"). - * - * Caveat: the count has no `expires_at` filter (it matches what the memory cap - * enforces), so it can over-count TTL-expired rows relative to the expiry-aware - * scope counts the Explorer shows. Degrades to 0 with no session. + * Every active memory the caller can see (personal + org), for the TopBar badge: + * the `total` of `GET /memories/quota`, read from the browser with the query's + * signal — never a server action (`packages/web/CLAUDE.md`). 0 with no session. */ export function useMemoryTotal() { return useQuery({ diff --git a/supabase/functions/_shared/limits/memory-quota.ts b/supabase/functions/_shared/limits/memory-quota.ts index 4137cd843..36fa7da79 100644 --- a/supabase/functions/_shared/limits/memory-quota.ts +++ b/supabase/functions/_shared/limits/memory-quota.ts @@ -1,16 +1,6 @@ /** - * The caller's memory quota: how many active memories they hold against the - * cap that will refuse their next write, and how many they can still write. - * - * Self-contained Deno mirror of `packages/mcp-core/src/limits/memory-quota.ts` — - * the edge tree cannot cross-import the Node package. `edge-parity.spec.ts` - * compares the two with comments stripped, so this file is free to say - * something different here but must stay behaviourally identical below. - * - * Why the personal pair and not the RPC's combined `count`: the cap trigger - * (00018) counts personal rows against the personal limit and org rows against - * each org's own limit, so a "remaining" from the combined count would be wrong - * for both. + * Self-contained Deno mirror of `packages/mcp-core/src/limits/memory-quota.ts`; `edge-parity.spec.ts` + * compares the two with comments stripped, so keep the code below behaviourally identical. */ /** diff --git a/supabase/functions/_shared/schemas/memory.ts b/supabase/functions/_shared/schemas/memory.ts index fa6adf4aa..94081e194 100644 --- a/supabase/functions/_shared/schemas/memory.ts +++ b/supabase/functions/_shared/schemas/memory.ts @@ -451,11 +451,8 @@ export type ScopesResponse = z.infer; // ── GET /memories/quota ────────────────────────────────────────────────────── /** - * `GET /memories/quota` — the caller's active memories against their plan's - * cap. Shaped by `memoryQuotaFromRow` (`packages/mcp-core/src/limits/memory-quota.ts`), - * which explains why it reports the PERSONAL pair rather than the combined - * count: the cap trigger counts personal rows against the personal limit and - * org rows against each org's own limit. + * `GET /memories/quota` — the caller's active memories against their plan's cap, + * shaped by `memoryQuotaFromRow` (`packages/mcp-core/src/limits/memory-quota.ts`). */ export const MemoryQuotaResponseSchema = z.object({ /** Plan name; `free` when the caller has no plan row. */ diff --git a/supabase/functions/mcp/tools.ts b/supabase/functions/mcp/tools.ts index b012181dd..4c1704b1f 100644 --- a/supabase/functions/mcp/tools.ts +++ b/supabase/functions/mcp/tools.ts @@ -1362,12 +1362,7 @@ export async function toolScopes( /** * `memory.quota` — the caller's active memories against their plan's cap. - * - * The same RPC and the same shaping as `GET /memories/quota` - * (`memories/handlers/quota.ts`): `lorekit_memory_count` is actor-guarded by - * 00047, and `memoryQuotaFromRow` reports the PERSONAL pair the cap trigger - * enforces rather than the RPC's combined count. Counts only, so a scoped key - * may call it — there is no scope or key name to narrow. + * Same RPC and shaping as `GET /memories/quota`; counts only, so a scoped key may call it. */ export async function toolQuota( db: DbClient, diff --git a/supabase/functions/memories/handlers/quota.ts b/supabase/functions/memories/handlers/quota.ts index c55d47844..cbbb045d9 100644 --- a/supabase/functions/memories/handlers/quota.ts +++ b/supabase/functions/memories/handlers/quota.ts @@ -6,19 +6,9 @@ import type { Span } from '../../_shared/telemetry/otel.ts'; import { memoryQuotaFromRow, type MemoryCountRow } from '../../_shared/limits/memory-quota.ts'; /** - * GET /memories/quota — the caller's active memories against their plan's cap, - * and how many writes are left before `POST /memories` is refused with - * `memory_cap`. The REST twin of the `memory.quota` MCP tool and `lorekit quota`. - * - * The count lives in Postgres (`lorekit_memory_count`, actor-guarded by 00047: - * a caller-supplied user id is honoured only on a service-role connection), so - * this handler only shapes it — see `memoryQuotaFromRow` for why the answer is - * the PERSONAL pair rather than the RPC's combined count. - * - * Counts only, never a scope or key name, so a scope-restricted key may read it: - * there is nothing for `p_key_scopes` to narrow (the same reason `/usage` is not - * name-bearing). A service-role caller has no user whose cap this could be, so - * it is refused rather than answered with zeros that look like an empty account. + * GET /memories/quota — the caller's active memories against their plan's cap. + * Counts only, so a scope-restricted key may read it; a service-role caller has + * no cap and is refused rather than shown zeros that look like an empty account. */ export async function handleQuota( _req: Request, auth: AuthContext, db: DbClient, span: Span, From a97e017f696688e80ef5dc155c9b6dea8d9bd953 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Wed, 21 Oct 2026 08:43:02 +0000 Subject: [PATCH 10/13] fix(quota): narrow org_count and total to the key's reachable orgs lorekit_memory_count counts every org the token owner belongs to, so a personal-only or selected-orgs key could read how many memories its owner's other orgs hold. GET /memories/quota and memory.quota now pass the calling key to quotaForKey, which recounts org_count over effectiveOrgIds (0 under personal, only org_ids under selected) and recomputes total. The personal pair is unchanged: the cap is account-wide. Refs: https://github.com/mthines/lorekit/pull/713#discussion_r4334606831 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../src/auth/tenant-scope-usage.spec.ts | 34 ++++++++ .../mcp-core/src/limits/memory-quota.spec.ts | 81 ++++++++++++++++++- packages/mcp-core/src/limits/memory-quota.ts | 40 ++++++++- .../functions/_shared/limits/memory-quota.ts | 40 ++++++++- .../_shared/limits/org-memory-count.ts | 32 ++++++++ supabase/functions/mcp/tools.ts | 11 ++- supabase/functions/memories/handlers/quota.ts | 21 +++-- 7 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 supabase/functions/_shared/limits/org-memory-count.ts diff --git a/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts b/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts index 2c639f7e6..c894f7618 100644 --- a/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts +++ b/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts @@ -585,3 +585,37 @@ describe('scope-authorized removal surfaces existed → reason (00071 / 00072)', expect(body).toContain("'not_found'"); }); }); + +/** + * Drift guard: `GET /memories/quota` and `memory.quota` pass the calling key's tenancy. + * + * `lorekit_memory_count` counts every org the OWNER belongs to and takes no key + * parameters, so the narrowing lives in `quotaForKey` (pure, behaviour-tested in + * `limits/memory-quota.spec.ts`). That holds only while both surfaces route + * through it with the key: one that returns `memoryQuotaFromRow(data)` directly + * compiles, runs, and shows a `personal` key every org's count. The edge is Deno + * and no vitest harness executes it, hence a source guard. + */ +describe('key-tenancy usage guard (quota org count)', () => { + it('GET /memories/quota narrows through quotaForKey with the calling key', () => { + const src = readHandlerSource('quota'); + expect(src).toMatch(/quotaForKey\(memoryQuotaFromRow\(data\),\s*keyRestriction\(auth\),/); + expect(src).toContain('countActiveOrgMemories(db, span, orgIds)'); + }); + + it('memory.quota narrows through quotaForKey with the calling key', () => { + const body = extractFunctionBody(source, 'toolQuota'); + expect(body).toMatch(/quotaForKey\(memoryQuotaFromRow\(data\),\s*keyScoping,/); + expect(body).toContain('countActiveOrgMemories(db, span, orgIds)'); + }); + + it('the shared org count reads only the org ids it is given, and only active rows', () => { + // The api_key path runs on a service-role client, so this filter is the boundary. + const src = readFileSync( + path.resolve(here, '../../../../supabase/functions/_shared/limits/org-memory-count.ts'), + 'utf8', + ); + expect(src).toContain(".in('org_id', orgIds)"); + expect(src).toContain(".is('archived_at', null)"); + }); +}); diff --git a/packages/mcp-core/src/limits/memory-quota.spec.ts b/packages/mcp-core/src/limits/memory-quota.spec.ts index fbb6fdf9b..b380eb508 100644 --- a/packages/mcp-core/src/limits/memory-quota.spec.ts +++ b/packages/mcp-core/src/limits/memory-quota.spec.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest'; -import { memoryQuotaFromRow } from './memory-quota'; +import { describe, expect, it, vi } from 'vitest'; +import type { KeyRestriction } from '../auth/tenant-scope'; +import { memoryQuotaFromRow, quotaForKey, type MemoryQuota } from './memory-quota'; describe('memoryQuotaFromRow', () => { it('reports the personal pair the cap enforces, not the combined count', () => { @@ -39,3 +40,79 @@ describe('memoryQuotaFromRow', () => { expect(memoryQuotaFromRow({ plan: ' ' }).plan).toBe('free'); }); }); + +describe('quotaForKey', () => { + // The owner belongs to three orgs holding 982 org memories between them. + const OWNER_QUOTA: MemoryQuota = { + plan: 'free', + personal: { count: 3318, limit: 5000, remaining: 1682 }, + org_count: 982, + total: 4300, + }; + const MEMBER_ORGS = ['org-a', 'org-b', 'org-c']; + + function fakeReads(orgCount = 0) { + return { + memberOrgIds: vi.fn(async () => MEMBER_ORGS), + countOrgMemories: vi.fn(async (_orgIds: string[]) => orgCount), + }; + } + const key = (orgAccess: KeyRestriction['orgAccess'], orgIds: string[] = []): KeyRestriction => + ({ scopes: [], orgAccess, orgIds }); + + it('leaves a JWT or unrestricted caller on the owner figure, without reading anything', async () => { + for (const k of [undefined, key('all')]) { + const reads = fakeReads(); + await expect(quotaForKey(OWNER_QUOTA, k, reads)).resolves.toBe(OWNER_QUOTA); + expect(reads.memberOrgIds).not.toHaveBeenCalled(); + expect(reads.countOrgMemories).not.toHaveBeenCalled(); + } + }); + + it('shows a personal-only key no org memories, and never counts any', async () => { + const reads = fakeReads(982); + const quota = await quotaForKey(OWNER_QUOTA, key('personal'), reads); + + expect(quota).toEqual({ ...OWNER_QUOTA, org_count: 0, total: 3318 }); + expect(reads.countOrgMemories).not.toHaveBeenCalled(); + }); + + it('counts a selected-orgs key over its orgs that the owner still belongs to', async () => { + // `org-gone` is a stale allowlist entry: the owner has since left that org. + const reads = fakeReads(140); + const quota = await quotaForKey(OWNER_QUOTA, key('selected', ['org-b', 'org-gone']), reads); + + expect(reads.countOrgMemories).toHaveBeenCalledWith(['org-b']); + expect(quota).toEqual({ ...OWNER_QUOTA, org_count: 140, total: 3458 }); + }); + + it('never narrows the personal pair, whatever the scope allowlist', async () => { + const scoped: KeyRestriction = { scopes: ['repo::acme/api'], orgAccess: 'personal', orgIds: [] }; + const quota = await quotaForKey(OWNER_QUOTA, scoped, fakeReads()); + + expect(quota.personal).toEqual(OWNER_QUOTA.personal); + }); + + it('skips the count when a selected key reaches none of the owner\'s orgs', async () => { + const reads = fakeReads(982); + const quota = await quotaForKey(OWNER_QUOTA, key('selected', ['org-gone']), reads); + + expect(quota).toEqual({ ...OWNER_QUOTA, org_count: 0, total: 3318 }); + expect(reads.countOrgMemories).not.toHaveBeenCalled(); + }); + + it('never reports more org memories than the owner figure, and coerces junk to 0', async () => { + const above = await quotaForKey(OWNER_QUOTA, key('selected', ['org-a']), fakeReads(5000)); + expect(above).toEqual({ ...OWNER_QUOTA, org_count: 982, total: 4300 }); + + const junk = await quotaForKey(OWNER_QUOTA, key('selected', ['org-a']), fakeReads(Number.NaN)); + expect(junk).toEqual({ ...OWNER_QUOTA, org_count: 0, total: 3318 }); + }); + + it('rejects when a read fails instead of falling back to the owner figure', async () => { + const reads = fakeReads(); + reads.countOrgMemories.mockRejectedValueOnce(new Error('db down')); + + await expect(quotaForKey(OWNER_QUOTA, key('selected', ['org-a']), reads)).rejects.toThrow('db down'); + }); +}); diff --git a/packages/mcp-core/src/limits/memory-quota.ts b/packages/mcp-core/src/limits/memory-quota.ts index 7ebc46097..9ddb419a3 100644 --- a/packages/mcp-core/src/limits/memory-quota.ts +++ b/packages/mcp-core/src/limits/memory-quota.ts @@ -4,6 +4,8 @@ * separate limits, so a "remaining" from the RPC's combined `count` would be wrong for both. */ +import { effectiveOrgIds, restrictsTenancy, type KeyRestriction } from '../auth/tenant-scope.ts'; + /** * The row `lorekit_memory_count` returns (migration 00047): jsonb, so every * field arrives untyped and is coerced below rather than trusted. @@ -22,12 +24,20 @@ export interface MemoryQuota { plan: string; /** Personal memories against the personal cap: the pair the cap trigger enforces. */ personal: { count: number; limit: number; remaining: number }; - /** Active org-owned memories across the caller's orgs. Each org has its own cap. */ + /** Active org-owned memories across the orgs the caller (and its key's tenancy) reaches. Each org has its own cap. */ org_count: number; /** `personal.count + org_count`: every active memory the caller can see. */ total: number; } +/** The two reads `quotaForKey` needs, passed in so this module does no I/O of its own. */ +export interface QuotaTenancyReads { + /** The key owner's member orgs (`lorekit_member_org_ids`). */ + memberOrgIds(): Promise; + /** Active (non-archived) org-owned memories across exactly these orgs. */ + countOrgMemories(orgIds: string[]): Promise; +} + const DEFAULT_PLAN = 'free'; function nonNegativeInteger(value: unknown): number { @@ -48,3 +58,31 @@ export function memoryQuotaFromRow(row: MemoryCountRow | null | undefined): Memo total: personalCount + orgCount, }; } + +/** + * The quota as the calling key may see it. + * + * `lorekit_memory_count` counts every org the OWNER belongs to. A key narrowed to `personal` + * or to selected orgs (00068) must not learn what the other orgs hold, so `org_count` and + * `total` are recounted over `effectiveOrgIds` — the same intersection every tenant-scoped + * read uses. `personal` is never narrowed: the cap is account-wide, so a count cut to the + * key's scope allowlist would overstate `remaining`. Without a key, or with one that reaches + * every org, neither read runs. Never reports more org memories than the owner's own figure. + * + * @param quota the owner's quota, from `memoryQuotaFromRow`. + * @param key the calling key's restriction; `undefined` for a JWT or an unrestricted caller. + * @param reads the membership read and the org count, supplied by each surface. + * @returns `quota` unchanged, or a copy with `org_count` and `total` recounted. + * @throws whatever `reads` throws; a failed read must not fall back to the owner's figure. + */ +export async function quotaForKey( + quota: MemoryQuota, + key: KeyRestriction | undefined, + reads: QuotaTenancyReads, +): Promise { + if (!restrictsTenancy(key)) return quota; + const orgIds = effectiveOrgIds(await reads.memberOrgIds(), key); + const reachable = orgIds.length === 0 ? 0 : nonNegativeInteger(await reads.countOrgMemories(orgIds)); + const orgCount = Math.min(reachable, quota.org_count); + return { ...quota, org_count: orgCount, total: quota.personal.count + orgCount }; +} diff --git a/supabase/functions/_shared/limits/memory-quota.ts b/supabase/functions/_shared/limits/memory-quota.ts index 36fa7da79..546be0660 100644 --- a/supabase/functions/_shared/limits/memory-quota.ts +++ b/supabase/functions/_shared/limits/memory-quota.ts @@ -3,6 +3,8 @@ * compares the two with comments stripped, so keep the code below behaviourally identical. */ +import { effectiveOrgIds, restrictsTenancy, type KeyRestriction } from '../auth/tenant-scope.ts'; + /** * The row `lorekit_memory_count` returns (migration 00047): jsonb, so every * field arrives untyped and is coerced below rather than trusted. @@ -21,12 +23,20 @@ export interface MemoryQuota { plan: string; /** Personal memories against the personal cap: the pair the cap trigger enforces. */ personal: { count: number; limit: number; remaining: number }; - /** Active org-owned memories across the caller's orgs. Each org has its own cap. */ + /** Active org-owned memories across the orgs the caller (and its key's tenancy) reaches. Each org has its own cap. */ org_count: number; /** `personal.count + org_count`: every active memory the caller can see. */ total: number; } +/** The two reads `quotaForKey` needs, passed in so this module does no I/O of its own. */ +export interface QuotaTenancyReads { + /** The key owner's member orgs (`lorekit_member_org_ids`). */ + memberOrgIds(): Promise; + /** Active (non-archived) org-owned memories across exactly these orgs. */ + countOrgMemories(orgIds: string[]): Promise; +} + const DEFAULT_PLAN = 'free'; function nonNegativeInteger(value: unknown): number { @@ -47,3 +57,31 @@ export function memoryQuotaFromRow(row: MemoryCountRow | null | undefined): Memo total: personalCount + orgCount, }; } + +/** + * The quota as the calling key may see it. + * + * `lorekit_memory_count` counts every org the OWNER belongs to. A key narrowed to `personal` + * or to selected orgs (00068) must not learn what the other orgs hold, so `org_count` and + * `total` are recounted over `effectiveOrgIds` — the same intersection every tenant-scoped + * read uses. `personal` is never narrowed: the cap is account-wide, so a count cut to the + * key's scope allowlist would overstate `remaining`. Without a key, or with one that reaches + * every org, neither read runs. Never reports more org memories than the owner's own figure. + * + * @param quota the owner's quota, from `memoryQuotaFromRow`. + * @param key the calling key's restriction; `undefined` for a JWT or an unrestricted caller. + * @param reads the membership read and the org count, supplied by each surface. + * @returns `quota` unchanged, or a copy with `org_count` and `total` recounted. + * @throws whatever `reads` throws; a failed read must not fall back to the owner's figure. + */ +export async function quotaForKey( + quota: MemoryQuota, + key: KeyRestriction | undefined, + reads: QuotaTenancyReads, +): Promise { + if (!restrictsTenancy(key)) return quota; + const orgIds = effectiveOrgIds(await reads.memberOrgIds(), key); + const reachable = orgIds.length === 0 ? 0 : nonNegativeInteger(await reads.countOrgMemories(orgIds)); + const orgCount = Math.min(reachable, quota.org_count); + return { ...quota, org_count: orgCount, total: quota.personal.count + orgCount }; +} diff --git a/supabase/functions/_shared/limits/org-memory-count.ts b/supabase/functions/_shared/limits/org-memory-count.ts new file mode 100644 index 000000000..a3d3f1768 --- /dev/null +++ b/supabase/functions/_shared/limits/org-memory-count.ts @@ -0,0 +1,32 @@ +/** + * The org half of `lorekit_memory_count`, recounted over a subset of the caller's orgs, for + * `quotaForKey` (`memory-quota.ts`). Shared by `GET /memories/quota` and `memory.quota`. + * Edge-only I/O, so it has no mcp-core twin; the decision about WHICH orgs lives in the + * mirrored, unit-tested `quotaForKey`. + */ + +import type { DbClient } from '../db/db-client.ts'; +import { createTracedClient, type Span } from '../telemetry/otel.ts'; + +/** + * Active (non-archived) org-owned memories across `orgIds`, matching the RPC's own org count. + * + * On the api_key path `db` is the service-role client, which bypasses RLS, so `orgIds` must + * already be the caller's member orgs intersected with the key's tenancy (`effectiveOrgIds`): + * that filter is the whole boundary here. + * + * @throws Error when the count query fails; the caller must not fall back to a wider figure. + */ +export async function countActiveOrgMemories(db: DbClient, span: Span, orgIds: string[]): Promise { + if (orgIds.length === 0) return 0; + const { count, error } = await createTracedClient(db, span) + .from('memories') + .select('id', { head: true, count: 'exact' }) + .in('org_id', orgIds) + .is('archived_at', null); + if (error) { + span.error(`DB: ${error.message}`); + throw new Error(error.message); + } + return count ?? 0; +} diff --git a/supabase/functions/mcp/tools.ts b/supabase/functions/mcp/tools.ts index 4c1704b1f..c0471233a 100644 --- a/supabase/functions/mcp/tools.ts +++ b/supabase/functions/mcp/tools.ts @@ -28,7 +28,8 @@ import { createTracedClient, type Span } from '../_shared/telemetry/otel.ts'; import { translateCapError } from './limits.ts'; import { translateOrgPermissionError } from './org-permissions.ts'; import { parseCreatedAt } from '../_shared/limits/created-at.ts'; -import { memoryQuotaFromRow, type MemoryCountRow } from '../_shared/limits/memory-quota.ts'; +import { memoryQuotaFromRow, quotaForKey, type MemoryCountRow } from '../_shared/limits/memory-quota.ts'; +import { countActiveOrgMemories } from '../_shared/limits/org-memory-count.ts'; import { parseTtl } from './ttl.ts'; import { parseOrigin } from '../_shared/provenance/origin.ts'; import { recordAuditDeferred } from '../_shared/audit/audit.ts'; @@ -1369,7 +1370,7 @@ export async function toolQuota( _params: Params, userId: string | null, span: Span, - _keyScoping?: KeyRestriction, + keyScoping?: KeyRestriction, ) { if (!userId) throw new UserInputError('memory.quota requires a user-scoped credential (service-role tokens have no user and no cap)'); @@ -1377,7 +1378,11 @@ export async function toolQuota( const { data, error } = await tracedDb.rpc('lorekit_memory_count', { p_user_id: userId }); if (error) throw new Error((error as { message: string }).message); - const quota = memoryQuotaFromRow(data); + // A key narrowed to `personal` or selected orgs sees only the orgs it reaches (00068). + const quota = await quotaForKey(memoryQuotaFromRow(data), keyScoping, { + memberOrgIds: () => memberOrgIds(db, userId), + countOrgMemories: (orgIds) => countActiveOrgMemories(db, span, orgIds), + }); span.setAttributes({ 'lorekit.plan': quota.plan, 'lorekit.memory.count': quota.personal.count, diff --git a/supabase/functions/memories/handlers/quota.ts b/supabase/functions/memories/handlers/quota.ts index cbbb045d9..732f7d339 100644 --- a/supabase/functions/memories/handlers/quota.ts +++ b/supabase/functions/memories/handlers/quota.ts @@ -1,31 +1,40 @@ import type { AuthContext } from '../../_shared/api/auth.ts'; import type { DbClient } from '../../_shared/api/auth.ts'; +import { keyRestriction } from '../../_shared/api/auth.ts'; import { ok, forbidden } from '../../_shared/api/respond.ts'; +import { getMemberOrgIds } from '../../_shared/api/tenant.ts'; import { createTracedClient } from '../../_shared/telemetry/otel.ts'; import type { Span } from '../../_shared/telemetry/otel.ts'; -import { memoryQuotaFromRow, type MemoryCountRow } from '../../_shared/limits/memory-quota.ts'; +import { memoryQuotaFromRow, quotaForKey, type MemoryCountRow } from '../../_shared/limits/memory-quota.ts'; +import { countActiveOrgMemories } from '../../_shared/limits/org-memory-count.ts'; /** * GET /memories/quota — the caller's active memories against their plan's cap. - * Counts only, so a scope-restricted key may read it; a service-role caller has - * no cap and is refused rather than shown zeros that look like an empty account. + * Counts only, so a scope-restricted key may read it; a key narrowed to `personal` + * or selected orgs sees `org_count` and `total` over only the orgs it reaches + * (`quotaForKey`). A service-role caller has no cap and is refused rather than + * shown zeros that look like an empty account. */ export async function handleQuota( _req: Request, auth: AuthContext, db: DbClient, span: Span, _params: Record, cors: Record, ): Promise { span.setAttributes({ 'lorekit.operation': 'memories.quota' }); - if (!auth.userId) { + const userId = auth.userId; + if (!userId) { return forbidden('Quota requires a user-scoped credential (service-role tokens have no user and no cap)', cors); } const tracedDb = createTracedClient(db, span); const { data, error } = await tracedDb.rpc('lorekit_memory_count', { - p_user_id: auth.userId, + p_user_id: userId, }); if (error) { span.error(`DB: ${error.message}`); throw error; } - const quota = memoryQuotaFromRow(data); + const quota = await quotaForKey(memoryQuotaFromRow(data), keyRestriction(auth), { + memberOrgIds: () => getMemberOrgIds(db, userId, span), + countOrgMemories: (orgIds) => countActiveOrgMemories(db, span, orgIds), + }); span.setAttributes({ 'lorekit.plan': quota.plan, 'lorekit.memory.count': quota.personal.count, From c88591ede744630797df620a142483519ab73121 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Wed, 21 Oct 2026 08:45:43 +0000 Subject: [PATCH 11/13] docs(quota): say what a tenancy-narrowed token sees from memory.quota docs/api-tokens.md gains a quota row in "What a scoped token sees". The llms.txt template no longer claims every operation without a scope is refused for a scoped token: only the account-wide sweeps are, and quota is answered with its org half narrowed. The catalog `returns`, the response schema, and the MCP, CLI and limits docs say the same; llms.txt and the edge schema mirror are regenerated. Refs: https://github.com/mthines/lorekit/pull/713#discussion_r4334606831 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- docs/api-tokens.md | 1 + docs/cli.md | 3 ++- docs/limits.md | 7 +++++-- docs/mcp-tools.md | 5 +++++ packages/cli/README.md | 3 ++- packages/cli/bin/lorekit.mjs | 3 ++- packages/mcp-core/src/limits/memory-quota.spec.ts | 2 +- packages/schemas/src/domain/memory.ts | 5 ++++- packages/schemas/src/llms/template.md | 7 +++++-- packages/schemas/src/shared/tool-catalog.ts | 3 ++- packages/web/public/llms.txt | 9 ++++++--- supabase/functions/_shared/schemas/memory.ts | 5 ++++- supabase/functions/_shared/schemas/tool-catalog.ts | 3 ++- 13 files changed, 41 insertions(+), 15 deletions(-) diff --git a/docs/api-tokens.md b/docs/api-tokens.md index 7ad7d88cc..60e21eae7 100644 --- a/docs/api-tokens.md +++ b/docs/api-tokens.md @@ -123,6 +123,7 @@ routed into an org the token was never granted. | A write addressed BY ID (`PATCH` update, `DELETE /:id`, `POST /memories/:id/restore`) | Filtered by the allowlist and personal-only — narrowed to the caller's own rows within its scopes, never widened to org rows or another writer's. | | A removal or restore that NAMES a scope+key (`DELETE ?scope=…&key=…`, the `POST /memories/restore` body form, and the MCP `memory.delete` / `memory.archive` / `memory.restore` tools) | **Scope-authorized.** For a scope INSIDE the allowlist a scoped token may archive / hard-delete / restore **any** writer's row for that `(scope, key)` — the management authority the owner granted by scoping the key — and a 0-row result is reported as `404` (nothing there) rather than `403` (present, but not this token's to touch), so "already gone" and "not yours" stay distinguishable. A scope OUTSIDE the allowlist is refused with a `403`, like any other named scope. An UNSCOPED token is unchanged — own-rows-only. The `&org=` delete form stays role-gated inside its RPC (org membership + write capability), never widened by the scope allowlist. | | Retention policies and grooming — `policy.list/create/update/delete`, `groom.preview/run` on MCP; `GET/POST /policies`, `PATCH/DELETE /policies/:id`, `POST /groom/preview`, `POST /groom/run` on REST | **Gated the same way `memory.*` is, on both surfaces.** `policy.create` / `POST /policies` refuses (MCP `-32003`, REST `403`) a target `scope` outside the allowlist. `policy.update` / `policy.delete` and their REST twins gate the policy's **stored** scope (fetched before the mutation, not the request body — a policy's scope cannot be changed by an update) — a scope-restricted token cannot rename or delete a policy fenced outside its allowlist, even by id. `policy.list` / `GET /policies` **narrows**: a fenced-scope policy is silently dropped rather than refusing the whole list. `groom.preview` / `groom.run` and their REST twins gate the RESOLVED scope — an inline `scope`, or (via `policy_id`) the referenced policy's stored scope — so a scoped token cannot preview or archive against a saved policy it could not itself have created. Before this, these seven operations only checked read/write PERMISSION, not the scope allowlist — a scoped token could create, list, update, delete, preview or run grooming for any scope its permission tier allowed, regardless of its allowlist. | +| Your quota — `memory.quota`, `GET /memories/quota`, and the `lorekit quota` command that calls it | **Answered for every token, and its org half narrowed to the tenancy.** It names no scope and returns counts only, so there is nothing to refuse. `org_count` and `total` cover only the orgs the token reaches: `0` under `personal`, and only the `org_ids` the owner still belongs to under `selected`. `personal` is always the owner's whole personal count, whatever the scope allowlist, because the cap it is measured against is account-wide: a count cut to the allowlist would overstate `remaining`. | | The token is unscoped (the default) | Nothing changes, on any path. | ## Permission matrix diff --git a/docs/cli.md b/docs/cli.md index bc6490778..bd60f67f4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -186,7 +186,8 @@ Remote-only: your active memories against your plan's cap and how many writes ar `GET /memories/quota` (the same answer as the `memory.quota` MCP tool). `personal` is the pair the cap trigger enforces — personal memories against the personal limit, `remaining` = limit − count, never negative; org-owned memories count against each org's own cap, so they are shown beside it -(`org_count`) and never subtracted. The shaping is the pure `memoryQuotaFromRow` +(`org_count`) and never subtracted; a token narrowed to `personal` or selected orgs sees `org_count` and +`total` over only the orgs it reaches. The shaping is the pure `memoryQuotaFromRow` (`packages/mcp-core/src/limits/memory-quota.ts`, mirrored to `_shared/limits/`). Warns at the cap and under 10 % headroom. Unlike the other read commands it **exits 1** when the remote is denied/unconfigured/unreachable/erroring, because there is no offline answer to fall back to and a diff --git a/docs/limits.md b/docs/limits.md index 8e1210e23..c3d4c4834 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -51,8 +51,11 @@ from `lorekit_memory_count` (00047), shaped by the pure `memoryQuotaFromRow` pair the trigger above enforces — personal active rows against the personal limit — so `remaining` is the number of new personal writes left. Org-owned rows count against each org's own cap (`00018_org_limits.sql`), so they are -reported as `org_count` beside it and never subtracted. A service-role caller -has no user and is refused. Per-org quotas are not reported yet. +reported as `org_count` beside it and never subtracted. A token narrowed to +`personal` or to selected orgs gets `org_count` and `total` recounted over only +the orgs it reaches (`quotaForKey`, same file, applying `effectiveOrgIds`); the +personal pair is never narrowed, because the cap is account-wide. A service-role +caller has no user and is refused. Per-org quotas are not reported yet. ### The cap is also a WRITE-COST parameter diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 2600d76be..9c65d711d 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -537,6 +537,11 @@ permission (`lk_rw_*` or `lk_ro_*`); a service-role token has no user and is ref so they are reported as `org_count` beside it and never subtracted. It is the same answer `GET /memories/quota` and the `lorekit quota` command return. +A token narrowed to `personal` or to selected orgs sees `org_count` and `total` over only the +orgs it reaches: `0` under `personal`. `personal` is never narrowed, by tenancy or by a scope +allowlist, because the cap it is measured against is account-wide +([what a scoped token sees](./api-tokens.md#what-a-scoped-token-sees)). + ```json { "params": { diff --git a/packages/cli/README.md b/packages/cli/README.md index bf5da7b90..3aa2b8902 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -419,7 +419,8 @@ lorekit quota --json # { available, endpoint, plan, personal, org_count, total ``` `personal` is the pair the cap enforces; org-owned memories count against each -org's own cap and are shown beside it, never subtracted. Exits 1 when the remote +org's own cap and are shown beside it, never subtracted. A token narrowed to +`personal` or selected orgs sees only the orgs it reaches. Exits 1 when the remote is denied, unconfigured, unreachable or erroring, so a script can gate a bulk write on it. diff --git a/packages/cli/bin/lorekit.mjs b/packages/cli/bin/lorekit.mjs index f98e7d750..26fe64a8d 100755 --- a/packages/cli/bin/lorekit.mjs +++ b/packages/cli/bin/lorekit.mjs @@ -568,7 +568,8 @@ writes are left before the remote refuses one with \`memory_cap\` — from "personal" is the pair the cap enforces: your personal memories against your personal limit. Org-owned memories count against each org's own cap, so they -are shown beside it and never subtracted from what is left. +are shown beside it and never subtracted from what is left. A token narrowed to +personal or selected orgs counts only the orgs it reaches. Remote only: the offline store has no plan and no cap. Exits 1 when the remote is denied, unconfigured, unreachable or erroring, so a script can gate a bulk diff --git a/packages/mcp-core/src/limits/memory-quota.spec.ts b/packages/mcp-core/src/limits/memory-quota.spec.ts index b380eb508..bbcc2debc 100644 --- a/packages/mcp-core/src/limits/memory-quota.spec.ts +++ b/packages/mcp-core/src/limits/memory-quota.spec.ts @@ -54,7 +54,7 @@ describe('quotaForKey', () => { function fakeReads(orgCount = 0) { return { memberOrgIds: vi.fn(async () => MEMBER_ORGS), - countOrgMemories: vi.fn(async (_orgIds: string[]) => orgCount), + countOrgMemories: vi.fn<(orgIds: string[]) => Promise>(async () => orgCount), }; } const key = (orgAccess: KeyRestriction['orgAccess'], orgIds: string[] = []): KeyRestriction => diff --git a/packages/schemas/src/domain/memory.ts b/packages/schemas/src/domain/memory.ts index 855e02f5b..e5e6cefc2 100644 --- a/packages/schemas/src/domain/memory.ts +++ b/packages/schemas/src/domain/memory.ts @@ -458,7 +458,10 @@ export const MemoryQuotaResponseSchema = z.object({ limit: z.number().int().nonnegative(), remaining: z.number().int().nonnegative(), }), - /** Active org-owned memories across the caller's orgs (each org has its own cap). */ + /** + * Active org-owned memories across the orgs the caller reaches (each org has its own cap): + * every org it belongs to, or only those its token's tenancy admits (`0` under `personal`). + */ org_count: z.number().int().nonnegative(), /** `personal.count + org_count` — every active memory the caller can see. */ total: z.number().int().nonnegative(), diff --git a/packages/schemas/src/llms/template.md b/packages/schemas/src/llms/template.md index e52736036..7c32317de 100644 --- a/packages/schemas/src/llms/template.md +++ b/packages/schemas/src/llms/template.md @@ -123,8 +123,11 @@ behaves exactly as it always has. A request that NAMES a scope outside the allowlist is refused (`-32003` on MCP, `403` on REST) rather than answered with an empty list. A request that names no scope is narrowed instead. `memory.scopes` is narrowed too, so a scoped token -cannot enumerate scope names it may not read. An operation that carries no scope -at all (`memory.purge_expired`) is refused for a token that has an allowlist. +cannot enumerate scope names it may not read. An account-wide sweep, which +carries no scope at all (`memory.purge`, `memory.purge_expired`), is refused for a +token that has an allowlist. `memory.quota` is answered for every token: it +returns counts only, `org_count` and `total` cover just the orgs the tenancy +reaches (`0` under `personal`), and `personal` stays account-wide because the cap is. For a scope INSIDE the allowlist, a scoped token can archive, delete, or restore any writer's row for that scope+key — the management authority the owner granted by scoping the key — reporting `404` (nothing there) vs `403` (present, but not this token's to touch); an unscoped token stays own-rows-only. diff --git a/packages/schemas/src/shared/tool-catalog.ts b/packages/schemas/src/shared/tool-catalog.ts index 4b8691b10..c3ebd0015 100644 --- a/packages/schemas/src/shared/tool-catalog.ts +++ b/packages/schemas/src/shared/tool-catalog.ts @@ -522,7 +522,8 @@ export const MCP_TOOLS = [ returns: '`{ "plan", "personal": { "count", "limit", "remaining" }, "org_count", "total" }`. `personal` is the ' + 'pair the cap enforces; org-owned memories count against each org\'s own cap, so `org_count` is ' - + 'reported beside it and not subtracted from `remaining`. A service-role caller has no user and is refused.', + + 'reported beside it and not subtracted from `remaining`. A token narrowed to `personal` or to selected orgs ' + + 'sees `org_count` and `total` over only the orgs it reaches. A service-role caller has no user and is refused.', }, { name: 'memory.list_archived', diff --git a/packages/web/public/llms.txt b/packages/web/public/llms.txt index 7656cea41..439af2669 100644 --- a/packages/web/public/llms.txt +++ b/packages/web/public/llms.txt @@ -134,8 +134,11 @@ behaves exactly as it always has. A request that NAMES a scope outside the allowlist is refused (`-32003` on MCP, `403` on REST) rather than answered with an empty list. A request that names no scope is narrowed instead. `memory.scopes` is narrowed too, so a scoped token -cannot enumerate scope names it may not read. An operation that carries no scope -at all (`memory.purge_expired`) is refused for a token that has an allowlist. +cannot enumerate scope names it may not read. An account-wide sweep, which +carries no scope at all (`memory.purge`, `memory.purge_expired`), is refused for a +token that has an allowlist. `memory.quota` is answered for every token: it +returns counts only, `org_count` and `total` cover just the orgs the tenancy +reaches (`0` under `personal`), and `personal` stays account-wide because the cap is. For a scope INSIDE the allowlist, a scoped token can archive, delete, or restore any writer's row for that scope+key — the management authority the owner granted by scoping the key — reporting `404` (nothing there) vs `403` (present, but not this token's to touch); an unscoped token stays own-rows-only. @@ -375,7 +378,7 @@ Requires **read** permission. MCP annotations: read-only, closed-world. -Returns: `{ "plan", "personal": { "count", "limit", "remaining" }, "org_count", "total" }`. `personal` is the pair the cap enforces; org-owned memories count against each org's own cap, so `org_count` is reported beside it and not subtracted from `remaining`. A service-role caller has no user and is refused. +Returns: `{ "plan", "personal": { "count", "limit", "remaining" }, "org_count", "total" }`. `personal` is the pair the cap enforces; org-owned memories count against each org's own cap, so `org_count` is reported beside it and not subtracted from `remaining`. A token narrowed to `personal` or to selected orgs sees `org_count` and `total` over only the orgs it reaches. A service-role caller has no user and is refused. --- diff --git a/supabase/functions/_shared/schemas/memory.ts b/supabase/functions/_shared/schemas/memory.ts index 94081e194..bef73bef6 100644 --- a/supabase/functions/_shared/schemas/memory.ts +++ b/supabase/functions/_shared/schemas/memory.ts @@ -463,7 +463,10 @@ export const MemoryQuotaResponseSchema = z.object({ limit: z.number().int().nonnegative(), remaining: z.number().int().nonnegative(), }), - /** Active org-owned memories across the caller's orgs (each org has its own cap). */ + /** + * Active org-owned memories across the orgs the caller reaches (each org has its own cap): + * every org it belongs to, or only those its token's tenancy admits (`0` under `personal`). + */ org_count: z.number().int().nonnegative(), /** `personal.count + org_count` — every active memory the caller can see. */ total: z.number().int().nonnegative(), diff --git a/supabase/functions/_shared/schemas/tool-catalog.ts b/supabase/functions/_shared/schemas/tool-catalog.ts index d40b3b8e9..d4f08e3c9 100644 --- a/supabase/functions/_shared/schemas/tool-catalog.ts +++ b/supabase/functions/_shared/schemas/tool-catalog.ts @@ -527,7 +527,8 @@ export const MCP_TOOLS = [ returns: '`{ "plan", "personal": { "count", "limit", "remaining" }, "org_count", "total" }`. `personal` is the ' + 'pair the cap enforces; org-owned memories count against each org\'s own cap, so `org_count` is ' - + 'reported beside it and not subtracted from `remaining`. A service-role caller has no user and is refused.', + + 'reported beside it and not subtracted from `remaining`. A token narrowed to `personal` or to selected orgs ' + + 'sees `org_count` and `total` over only the orgs it reaches. A service-role caller has no user and is refused.', }, { name: 'memory.list_archived', From eb295a1e698614283c40ba1f901b2ec20044d2f1 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Wed, 21 Oct 2026 09:03:28 +0000 Subject: [PATCH 12/13] address review comment: OpenAPI quota description names the token narrowing Addresses the pr-reviewer comment: https://github.com/mthines/lorekit/pull/713#discussion_r4334893025 Refs: https://github.com/mthines/lorekit/pull/713 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/schemas/src/openapi/spec.ts | 5 +++-- supabase/functions/_shared/schemas/openapi/spec.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/schemas/src/openapi/spec.ts b/packages/schemas/src/openapi/spec.ts index de26f7429..4fb8f1766 100644 --- a/packages/schemas/src/openapi/spec.ts +++ b/packages/schemas/src/openapi/spec.ts @@ -278,8 +278,9 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co '`personal` is the pair the memory cap enforces: personal memories against the personal limit, ' + 'with `remaining` writes left before `POST /memories` is refused with `memory_cap`. Org-owned ' + 'memories count against each org\'s own cap, so they are reported as `org_count` and are not ' - + 'subtracted from `remaining`. Requires a user-scoped credential; a service-role token has no ' - + 'user and gets 403.', + + 'subtracted from `remaining`. A token narrowed to `personal` or to selected orgs gets ' + + '`org_count` and `total` over only the orgs it reaches (`0` under `personal`). Requires a ' + + 'user-scoped credential; a service-role token has no user and gets 403.', security, responses: { 200: { description: 'Quota', content: { 'application/json': { schema: MemoryQuotaResponseSchema } } }, diff --git a/supabase/functions/_shared/schemas/openapi/spec.ts b/supabase/functions/_shared/schemas/openapi/spec.ts index a448aa0bd..d3d7032d0 100644 --- a/supabase/functions/_shared/schemas/openapi/spec.ts +++ b/supabase/functions/_shared/schemas/openapi/spec.ts @@ -283,8 +283,9 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co '`personal` is the pair the memory cap enforces: personal memories against the personal limit, ' + 'with `remaining` writes left before `POST /memories` is refused with `memory_cap`. Org-owned ' + 'memories count against each org\'s own cap, so they are reported as `org_count` and are not ' - + 'subtracted from `remaining`. Requires a user-scoped credential; a service-role token has no ' - + 'user and gets 403.', + + 'subtracted from `remaining`. A token narrowed to `personal` or to selected orgs gets ' + + '`org_count` and `total` over only the orgs it reaches (`0` under `personal`). Requires a ' + + 'user-scoped credential; a service-role token has no user and gets 403.', security, responses: { 200: { description: 'Quota', content: { 'application/json': { schema: MemoryQuotaResponseSchema } } }, From f76e8f28bcb9c4d6a6c0a3dc904e1041e18a7ea0 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Wed, 21 Oct 2026 09:05:09 +0000 Subject: [PATCH 13/13] chore: simplify pass (mechanical refactors) Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../mcp-core/src/auth/tenant-scope-usage.spec.ts | 13 +++---------- packages/mcp-core/src/limits/memory-quota.ts | 12 +++--------- supabase/functions/_shared/limits/memory-quota.ts | 12 +++--------- .../functions/_shared/limits/org-memory-count.ts | 7 ++----- supabase/functions/memories/handlers/quota.ts | 7 ++----- 5 files changed, 13 insertions(+), 38 deletions(-) diff --git a/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts b/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts index c894f7618..a26e29196 100644 --- a/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts +++ b/packages/mcp-core/src/auth/tenant-scope-usage.spec.ts @@ -586,16 +586,9 @@ describe('scope-authorized removal surfaces existed → reason (00071 / 00072)', }); }); -/** - * Drift guard: `GET /memories/quota` and `memory.quota` pass the calling key's tenancy. - * - * `lorekit_memory_count` counts every org the OWNER belongs to and takes no key - * parameters, so the narrowing lives in `quotaForKey` (pure, behaviour-tested in - * `limits/memory-quota.spec.ts`). That holds only while both surfaces route - * through it with the key: one that returns `memoryQuotaFromRow(data)` directly - * compiles, runs, and shows a `personal` key every org's count. The edge is Deno - * and no vitest harness executes it, hence a source guard. - */ +// `lorekit_memory_count` takes no key, so tenancy narrowing lives in `quotaForKey` (behaviour-tested in +// `limits/memory-quota.spec.ts`). A surface returning `memoryQuotaFromRow(data)` directly would show a +// `personal` key every org's count, and no vitest harness runs the Deno edge, hence a source guard. describe('key-tenancy usage guard (quota org count)', () => { it('GET /memories/quota narrows through quotaForKey with the calling key', () => { const src = readHandlerSource('quota'); diff --git a/packages/mcp-core/src/limits/memory-quota.ts b/packages/mcp-core/src/limits/memory-quota.ts index 9ddb419a3..4f092c202 100644 --- a/packages/mcp-core/src/limits/memory-quota.ts +++ b/packages/mcp-core/src/limits/memory-quota.ts @@ -60,15 +60,9 @@ export function memoryQuotaFromRow(row: MemoryCountRow | null | undefined): Memo } /** - * The quota as the calling key may see it. - * - * `lorekit_memory_count` counts every org the OWNER belongs to. A key narrowed to `personal` - * or to selected orgs (00068) must not learn what the other orgs hold, so `org_count` and - * `total` are recounted over `effectiveOrgIds` — the same intersection every tenant-scoped - * read uses. `personal` is never narrowed: the cap is account-wide, so a count cut to the - * key's scope allowlist would overstate `remaining`. Without a key, or with one that reaches - * every org, neither read runs. Never reports more org memories than the owner's own figure. - * + * The quota as the calling key may see it: for a key narrowed to `personal` or selected orgs (00068), + * `org_count` and `total` recounted over `effectiveOrgIds`, capped at the owner's figure; `personal` + * is never narrowed because the cap is account-wide. * @param quota the owner's quota, from `memoryQuotaFromRow`. * @param key the calling key's restriction; `undefined` for a JWT or an unrestricted caller. * @param reads the membership read and the org count, supplied by each surface. diff --git a/supabase/functions/_shared/limits/memory-quota.ts b/supabase/functions/_shared/limits/memory-quota.ts index 546be0660..7ecbdec5a 100644 --- a/supabase/functions/_shared/limits/memory-quota.ts +++ b/supabase/functions/_shared/limits/memory-quota.ts @@ -59,15 +59,9 @@ export function memoryQuotaFromRow(row: MemoryCountRow | null | undefined): Memo } /** - * The quota as the calling key may see it. - * - * `lorekit_memory_count` counts every org the OWNER belongs to. A key narrowed to `personal` - * or to selected orgs (00068) must not learn what the other orgs hold, so `org_count` and - * `total` are recounted over `effectiveOrgIds` — the same intersection every tenant-scoped - * read uses. `personal` is never narrowed: the cap is account-wide, so a count cut to the - * key's scope allowlist would overstate `remaining`. Without a key, or with one that reaches - * every org, neither read runs. Never reports more org memories than the owner's own figure. - * + * The quota as the calling key may see it: for a key narrowed to `personal` or selected orgs (00068), + * `org_count` and `total` recounted over `effectiveOrgIds`, capped at the owner's figure; `personal` + * is never narrowed because the cap is account-wide. * @param quota the owner's quota, from `memoryQuotaFromRow`. * @param key the calling key's restriction; `undefined` for a JWT or an unrestricted caller. * @param reads the membership read and the org count, supplied by each surface. diff --git a/supabase/functions/_shared/limits/org-memory-count.ts b/supabase/functions/_shared/limits/org-memory-count.ts index a3d3f1768..790be2014 100644 --- a/supabase/functions/_shared/limits/org-memory-count.ts +++ b/supabase/functions/_shared/limits/org-memory-count.ts @@ -10,15 +10,12 @@ import { createTracedClient, type Span } from '../telemetry/otel.ts'; /** * Active (non-archived) org-owned memories across `orgIds`, matching the RPC's own org count. - * - * On the api_key path `db` is the service-role client, which bypasses RLS, so `orgIds` must - * already be the caller's member orgs intersected with the key's tenancy (`effectiveOrgIds`): - * that filter is the whole boundary here. - * * @throws Error when the count query fails; the caller must not fall back to a wider figure. */ export async function countActiveOrgMemories(db: DbClient, span: Span, orgIds: string[]): Promise { if (orgIds.length === 0) return 0; + // The api_key path runs on the service-role client, which bypasses RLS: `orgIds` must already be + // membership ∩ the key's tenancy (`effectiveOrgIds`), because this filter is the whole boundary. const { count, error } = await createTracedClient(db, span) .from('memories') .select('id', { head: true, count: 'exact' }) diff --git a/supabase/functions/memories/handlers/quota.ts b/supabase/functions/memories/handlers/quota.ts index 732f7d339..e9cc13ae0 100644 --- a/supabase/functions/memories/handlers/quota.ts +++ b/supabase/functions/memories/handlers/quota.ts @@ -9,11 +9,8 @@ import { memoryQuotaFromRow, quotaForKey, type MemoryCountRow } from '../../_sha import { countActiveOrgMemories } from '../../_shared/limits/org-memory-count.ts'; /** - * GET /memories/quota — the caller's active memories against their plan's cap. - * Counts only, so a scope-restricted key may read it; a key narrowed to `personal` - * or selected orgs sees `org_count` and `total` over only the orgs it reaches - * (`quotaForKey`). A service-role caller has no cap and is refused rather than - * shown zeros that look like an empty account. + * GET /memories/quota — the caller's active memories against their plan's cap, with the org half + * narrowed to the calling key's tenancy (`quotaForKey`); a service-role caller has no cap and is refused. */ export async function handleQuota( _req: Request, auth: AuthContext, db: DbClient, span: Span,