From c3bbea51344255a89be317238849b32729d17cf1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 11 Aug 2026 14:58:11 -0700 Subject: [PATCH] feat: add memory.patch, trim memory.get, unify the memory write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit memory.get no longer returns the embedding and content_tsv ---------------------------------------------------------- It used a bare select() and returned the raw DB row, while memory.list and memory.search already projected an explicit 9-field shape. On a ~13k-char memory those two internal columns were 55% of the response and pushed it past the MCP tool-output cap, so large memories could not be fetched inline at all. memory.get now returns the same 9 fields as its siblings; user_id is still selected for the authorization check and stripped before responding. memory.patch ------------ memory.update only accepts full replacement, so adding one line to a large document meant resending the whole document — expensive enough that edits were being skipped rather than risk silently truncating shared team documents. memory.patch replaces one exact occurrence of old_string. An absent or ambiguous match is an error, never a silent no-op and never an arbitrary pick; that refusal is what makes the operation safe to hand to an agent. The semantics live in lib/memory-patch.ts as a pure function, free of DB and auth, so both surfaces share them. Shared mutation layer --------------------- The MCP tools and the Web UI Server Actions each reimplemented authorize -> mutate -> re-embed -> CAS -> audit, and had drifted. Both now route through lib/memory-mutations.ts. BEHAVIOUR CHANGE: memory.delete over MCP skipped the project ACL whenever the caller authored the row, so a memory written while a share was rw stayed deletable by its author after an owner downgraded that share to ro. memory.update and the whole Web UI always checked. Authoring a row now grants no standing write privilege on any path. The one deliberate difference between the surfaces is injected as a ProjectResolver: MCP refuses an unknown project key so an agent cannot spawn near-miss projects off a typo, while the Web UI creates one because a person typing a name into a form means to. Tests and lint -------------- Adds vitest. The integration tests run against a real Postgres rather than a mocked DB. The embedder sidecar is the only stub and it is deterministic per-text, so re-embedding is verified by asserting the stored vector actually changed rather than that a mock was called. One test pins that content_tsv is a generated column and therefore cannot rot after a patch — only the embedding needs an explicit recompute. pnpm lint previously dropped into an interactive `next lint` setup prompt and exited 1; ESLint had never been configured here. Replaced with the ESLint CLI and a flat config bridging eslint-config-next through FlatCompat. Clean at --max-warnings=0. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 60 ++ apps/web/app/(authed)/dashboard/page.tsx | 2 +- .../(authed)/projects/[key]/activity/page.tsx | 2 +- .../web/app/(authed)/settings/tokens/page.tsx | 2 +- apps/web/eslint.config.mjs | 33 + apps/web/lib/mcp/tools.integration.test.ts | 258 ++++++ apps/web/lib/mcp/tools.mutations.test.ts | 244 ++++++ apps/web/lib/mcp/tools.ts | 331 +++---- apps/web/lib/memory-actions.ts | 361 ++------ apps/web/lib/memory-mutations.test.ts | 263 ++++++ apps/web/lib/memory-mutations.ts | 357 ++++++++ apps/web/lib/memory-patch.test.ts | 77 ++ apps/web/lib/memory-patch.ts | 76 ++ apps/web/package.json | 10 +- apps/web/postcss.config.mjs | 4 +- apps/web/vitest.config.ts | 18 + apps/web/vitest.setup.ts | 25 + package.json | 1 + packages/schemas/src/index.ts | 16 +- pnpm-lock.yaml | 822 +++++++++++++++++- 20 files changed, 2435 insertions(+), 527 deletions(-) create mode 100644 apps/web/eslint.config.mjs create mode 100644 apps/web/lib/mcp/tools.integration.test.ts create mode 100644 apps/web/lib/mcp/tools.mutations.test.ts create mode 100644 apps/web/lib/memory-mutations.test.ts create mode 100644 apps/web/lib/memory-mutations.ts create mode 100644 apps/web/lib/memory-patch.test.ts create mode 100644 apps/web/lib/memory-patch.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 apps/web/vitest.setup.ts diff --git a/README.md b/README.md index 2247d2b..e67ffc1 100644 --- a/README.md +++ b/README.md @@ -595,6 +595,66 @@ The OIDC client you use locally must accept --- +## Running the tests + +```bash +pnpm test # all packages +pnpm --filter @shared-memory/web test:watch +``` + +Unit tests (e.g. `lib/memory-patch.test.ts`) need nothing but `pnpm install`. + +The integration tests in `lib/mcp/tools.integration.test.ts` exercise the +real tool handlers against a **real Postgres with pgvector** — they assert on +stored rows, so there is no mock DB to drift from production behaviour. Spin +one up: + +```bash +docker run -d --name sm-test-db \ + -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test \ + -e POSTGRES_DB=shared_memory_test \ + -p 55432:5432 pgvector/pgvector:pg16 + +for f in apps/web/drizzle/*.sql; do + docker exec -i sm-test-db psql -U test -d shared_memory_test -v ON_ERROR_STOP=1 -q < "$f" +done + +pnpm test +``` + +The default `DATABASE_URL` assumes the published port is reachable on +localhost. If your test runner is itself inside a container, point it at the +database container's address instead: + +```bash +DATABASE_URL="postgres://test:test@$(docker inspect -f \ + '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' sm-test-db):5432/shared_memory_test" \ + pnpm test +``` + +The embedder sidecar is stubbed in tests (it's an external ML service); the +stub is deterministic per-text, so re-embedding is verified by asserting the +stored vector actually changed — not by asserting a mock was called. + +Teardown: `docker rm -f sm-test-db`. + +### Linting + +```bash +pnpm lint +``` + +Runs the ESLint CLI directly against `eslint.config.mjs`. Note that `next +lint` is deprecated (it goes away in Next 16) and had never been configured +here, so this replaces it. `eslint-config-next` is still published in the +legacy `.eslintrc` format, so the config bridges it through `FlatCompat`; +that bridge can be dropped once the package ships a native flat export. + +The tree is currently clean at `--max-warnings=0`, so adding that flag to +the `lint` script is a cheap way to keep it that way. + +--- + ## Troubleshooting - **`401 claim invalid: aud`** from `/api/mcp` — your MCP client isn't diff --git a/apps/web/app/(authed)/dashboard/page.tsx b/apps/web/app/(authed)/dashboard/page.tsx index efb2dab..5799d7d 100644 --- a/apps/web/app/(authed)/dashboard/page.tsx +++ b/apps/web/app/(authed)/dashboard/page.tsx @@ -5,7 +5,7 @@ import { db } from "@/lib/db/client"; import { memories, projects, projectShares } from "@/lib/db/schema"; import { getUserGroupNames, readableProjectIds } from "@/lib/access"; import { Container, PageHeader } from "@/app/_components/ui/container"; -import { Card, CardBody, CardHeader } from "@/app/_components/ui/card"; +import { Card, CardBody } from "@/app/_components/ui/card"; import { Badge } from "@/app/_components/ui/badge"; import { Button } from "@/app/_components/ui/button"; import { EmptyState } from "@/app/_components/ui/empty-state"; diff --git a/apps/web/app/(authed)/projects/[key]/activity/page.tsx b/apps/web/app/(authed)/projects/[key]/activity/page.tsx index ba43ba0..559eb8a 100644 --- a/apps/web/app/(authed)/projects/[key]/activity/page.tsx +++ b/apps/web/app/(authed)/projects/[key]/activity/page.tsx @@ -6,7 +6,7 @@ import { db, pg } from "@/lib/db/client"; import { projects, users } from "@/lib/db/schema"; import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access"; import { Container, PageHeader } from "@/app/_components/ui/container"; -import { Card, CardBody } from "@/app/_components/ui/card"; +import { Card } from "@/app/_components/ui/card"; import { Badge } from "@/app/_components/ui/badge"; import { Button } from "@/app/_components/ui/button"; import { EmptyState } from "@/app/_components/ui/empty-state"; diff --git a/apps/web/app/(authed)/settings/tokens/page.tsx b/apps/web/app/(authed)/settings/tokens/page.tsx index 94829ab..eda77a8 100644 --- a/apps/web/app/(authed)/settings/tokens/page.tsx +++ b/apps/web/app/(authed)/settings/tokens/page.tsx @@ -1,5 +1,5 @@ import { revalidatePath } from "next/cache"; -import { and, asc, desc, eq, isNull } from "drizzle-orm"; +import { and, asc, desc, eq } from "drizzle-orm"; import { auth } from "@/auth"; import { env } from "@/lib/env"; import { db } from "@/lib/db/client"; diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..00d334d --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,33 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { FlatCompat } from "@eslint/eslintrc"; + +/** + * ESLint flat config. + * + * `next lint` is deprecated (removed in Next 16) and was never configured + * here, so `pnpm lint` used to drop into an interactive setup prompt and + * exit non-zero. This runs the ESLint CLI directly instead. + * + * `eslint-config-next` is still published in the legacy .eslintrc format, + * so FlatCompat bridges it into flat config. That bridge goes away when + * the config ships a native flat export. + */ +const compat = new FlatCompat({ + baseDirectory: dirname(fileURLToPath(import.meta.url)), +}); + +const config = [ + { + ignores: [ + ".next/**", + "node_modules/**", + "next-env.d.ts", + // Generated SQL/journal artifacts from drizzle-kit. + "drizzle/**", + ], + }, + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default config; diff --git a/apps/web/lib/mcp/tools.integration.test.ts b/apps/web/lib/mcp/tools.integration.test.ts new file mode 100644 index 0000000..cc58c8c --- /dev/null +++ b/apps/web/lib/mcp/tools.integration.test.ts @@ -0,0 +1,258 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; + +/** + * Integration tests for memory.get and memory.patch against a REAL + * Postgres (pgvector). See CONTRIBUTING/README for spinning up the test + * database; without it these tests fail to connect rather than silently + * passing. + * + * The embedder sidecar is the one thing stubbed — it's an external HTTP + * service running an ML model. The stub is deterministic per-text, which + * lets the re-embedding test assert on the STORED VECTOR CHANGING (real + * DB state) rather than on "was the mock called". + */ +vi.mock("@/lib/embedder", () => ({ + embedText: async (text: string) => { + // Deterministic pseudo-vector: distinct texts produce distinct vectors. + let h = 0; + for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0; + return Array.from({ length: 384 }, (_, i) => ((h + i * 7919) % 1000) / 1000); + }, + embedTexts: async (texts: string[]) => texts.map(() => Array(384).fill(0.1)), + embedderReady: async () => true, + EmbedderError: class extends Error {}, +})); + +const { db, pg } = await import("@/lib/db/client"); +const { memories, projects, users } = await import("@/lib/db/schema"); +const { toolMap } = await import("@/lib/mcp/tools"); +const { eq } = await import("drizzle-orm"); +type UserContext = import("@/lib/mcp/context").UserContext; + +const ORIGINAL = [ + "# Roadmap", + "", + "## RECENTLY SHIPPED", + "- v1.0 initial release", + "", + "## IN PROGRESS", + "- patch primitive", + "", +].join("\n"); + +let userId: string; +let projectId: string; +let memoryId: string; +let ctx: UserContext; + +async function seedMemory(content = ORIGINAL): Promise { + const row = await db + .insert(memories) + .values({ + userId, + projectId, + scope: "project", + content, + tags: ["roadmap"], + embedding: Array(384).fill(0.5), + }) + .returning({ id: memories.id }); + return row[0]!.id; +} + +async function readContent(id: string): Promise { + const r = await db + .select({ content: memories.content }) + .from(memories) + .where(eq(memories.id, id)); + return r[0]!.content; +} + +beforeAll(async () => { + const u = await db + .insert(users) + .values({ oidcSub: "test-sub", oidcIss: "http://test", email: "t@example.com" }) + .onConflictDoNothing() + .returning({ id: users.id }); + userId = + u[0]?.id ?? + (await db.select({ id: users.id }).from(users).limit(1))[0]!.id; + + const p = await db + .insert(projects) + .values({ userId, key: "test-project", displayName: "Test Project" }) + .onConflictDoNothing() + .returning({ id: projects.id }); + projectId = + p[0]?.id ?? + (await db.select({ id: projects.id }).from(projects).limit(1))[0]!.id; + + ctx = { + userId, + sub: "test-sub", + iss: "http://test", + email: null, + name: null, + groups: [], + }; +}); + +beforeEach(async () => { + memoryId = await seedMemory(); +}); + +afterAll(async () => { + await db.delete(memories); + await pg.end(); +}); + +describe("memory.get response shape (P1)", () => { + test("does not leak the embedding or the tsvector to the caller", async () => { + const res = await toolMap["memory.get"]!.handler({ id: memoryId }, ctx); + const fields = Object.keys(res.structuredContent as object); + + expect(fields).not.toContain("embedding"); + expect(fields).not.toContain("contentTsv"); + }); + + test("returns exactly the same 9 fields as memory.list", async () => { + const res = await toolMap["memory.get"]!.handler({ id: memoryId }, ctx); + const fields = Object.keys(res.structuredContent as object).sort(); + + expect(fields).toEqual( + [ + "content", + "createdAt", + "id", + "lastEditedBy", + "projectId", + "scope", + "tags", + "updatedAt", + "version", + ].sort(), + ); + }); + + test("still returns the full content", async () => { + const res = await toolMap["memory.get"]!.handler({ id: memoryId }, ctx); + expect((res.structuredContent as { content: string }).content).toBe(ORIGINAL); + }); +}); + +describe("memory.patch (P2)", () => { + test("applies a unique patch and increments version by exactly 1", async () => { + const before = await db + .select({ version: memories.version }) + .from(memories) + .where(eq(memories.id, memoryId)); + + const res = await toolMap["memory.patch"]!.handler( + { + id: memoryId, + old_string: "## RECENTLY SHIPPED", + new_string: "## RECENTLY SHIPPED\n- v1.1 patch primitive", + }, + ctx, + ); + + expect(res.isError).toBeFalsy(); + const after = res.structuredContent as { version: number }; + expect(after.version).toBe(before[0]!.version + 1); + expect(await readContent(memoryId)).toContain("- v1.1 patch primitive"); + // The rest of the document survived. + expect(await readContent(memoryId)).toContain("- v1.0 initial release"); + expect(await readContent(memoryId)).toContain("## IN PROGRESS"); + }); + + test("refuses an absent old_string and leaves content byte-identical", async () => { + const res = await toolMap["memory.patch"]!.handler( + { id: memoryId, old_string: "## NOT PRESENT", new_string: "x" }, + ctx, + ); + + expect(res.isError).toBe(true); + expect(await readContent(memoryId)).toBe(ORIGINAL); + }); + + test("refuses an ambiguous old_string, naming the count, leaving content unchanged", async () => { + const id = await seedMemory("alpha\nalpha\nbeta\n"); + + const res = await toolMap["memory.patch"]!.handler( + { id, old_string: "alpha", new_string: "gamma" }, + ctx, + ); + + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toMatch(/2/); + expect(await readContent(id)).toBe("alpha\nalpha\nbeta\n"); + }); + + test("refuses a stale version and leaves content unchanged", async () => { + const current = await db + .select({ version: memories.version }) + .from(memories) + .where(eq(memories.id, memoryId)); + + const res = await toolMap["memory.patch"]!.handler( + { + id: memoryId, + old_string: "## IN PROGRESS", + new_string: "## DONE", + version: current[0]!.version + 99, + }, + ctx, + ); + + expect(res.isError).toBe(true); + expect(await readContent(memoryId)).toBe(ORIGINAL); + }); + + test("rejects a patch that would push content past the 64,000-char limit", async () => { + const id = await seedMemory("A".repeat(63_950) + "ANCHOR"); + + const res = await toolMap["memory.patch"]!.handler( + { id, old_string: "ANCHOR", new_string: "B".repeat(100) }, + ctx, + ); + + expect(res.isError).toBe(true); + expect(await readContent(id)).toBe("A".repeat(63_950) + "ANCHOR"); + }); + + test("re-embeds: the stored vector changes after a patch", async () => { + const before = await pg<{ embedding: string }[]>` + SELECT embedding::text AS embedding FROM memories WHERE id = ${memoryId} + `; + + await toolMap["memory.patch"]!.handler( + { id: memoryId, old_string: "- patch primitive", new_string: "- shipped it" }, + ctx, + ); + + const after = await pg<{ embedding: string }[]>` + SELECT embedding::text AS embedding FROM memories WHERE id = ${memoryId} + `; + + expect(after[0]!.embedding).not.toBe(before[0]!.embedding); + }); + + test("full-text index updates itself, because content_tsv is a generated column", async () => { + // This is the claim that a patch cannot rot FTS. Postgres maintains + // content_tsv; only the embedding needs an explicit recompute. + await toolMap["memory.patch"]!.handler( + { + id: memoryId, + old_string: "- patch primitive", + new_string: "- kumquat marmalade", + }, + ctx, + ); + + const hit = await pg<{ n: number }[]>` + SELECT count(*)::int AS n FROM memories + WHERE id = ${memoryId} AND content_tsv @@ plainto_tsquery('english', 'kumquat') + `; + expect(hit[0]!.n).toBe(1); + }); +}); diff --git a/apps/web/lib/mcp/tools.mutations.test.ts b/apps/web/lib/mcp/tools.mutations.test.ts new file mode 100644 index 0000000..344153b --- /dev/null +++ b/apps/web/lib/mcp/tools.mutations.test.ts @@ -0,0 +1,244 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; + +/** + * Integration cover for the memory MUTATION paths (write / update / delete) + * against a real Postgres. These exist mainly as a safety net for the + * shared-mutation refactor: the MCP tools and the Web UI Server Actions + * used to reimplement the same authorize → CAS → re-embed → audit sequence + * separately, and these assertions pin the behaviour that must survive + * being pulled into one place. + */ +vi.mock("@/lib/embedder", () => ({ + embedText: async (text: string) => { + let h = 0; + for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0; + return Array.from({ length: 384 }, (_, i) => ((h + i * 7919) % 1000) / 1000); + }, + embedTexts: async (texts: string[]) => texts.map(() => Array(384).fill(0.1)), + embedderReady: async () => true, + EmbedderError: class extends Error {}, +})); + +const { db, pg } = await import("@/lib/db/client"); +const { memories, projects, users, groups, userGroups, projectShares } = await import( + "@/lib/db/schema" +); +const { toolMap } = await import("@/lib/mcp/tools"); +const { eq } = await import("drizzle-orm"); +type UserContext = import("@/lib/mcp/context").UserContext; + +const ISS = "http://test"; + +let author: UserContext; +let projectOwnerId: string; +let ownProjectId: string; +let sharedProjectId: string; +let sharedGroupId: string; + +function ctxFor(userId: string, sub: string, groupNames: string[] = []): UserContext { + return { userId, sub, iss: ISS, email: null, name: null, groups: groupNames }; +} + +async function upsertUser(sub: string): Promise { + const r = await db + .insert(users) + .values({ oidcSub: sub, oidcIss: ISS }) + .onConflictDoUpdate({ target: [users.oidcIss, users.oidcSub], set: { oidcSub: sub } }) + .returning({ id: users.id }); + return r[0]!.id; +} + +async function seedMemory( + userId: string, + projectId: string | null, + content = "seed content", +): Promise { + const r = await db + .insert(memories) + .values({ + userId, + projectId, + scope: projectId ? "project" : "user", + content, + tags: [], + embedding: Array(384).fill(0.5), + lastEditedBy: userId, + }) + .returning({ id: memories.id }); + return r[0]!.id; +} + +async function setShareAccess(access: "ro" | "rw") { + await db + .insert(projectShares) + .values({ projectId: sharedProjectId, groupId: sharedGroupId, access }) + .onConflictDoUpdate({ + target: [projectShares.projectId, projectShares.groupId], + set: { access }, + }); +} + +async function isDeleted(id: string): Promise { + const r = await db + .select({ deletedAt: memories.deletedAt }) + .from(memories) + .where(eq(memories.id, id)); + return r[0]!.deletedAt !== null; +} + +beforeAll(async () => { + const authorId = await upsertUser("author-sub"); + projectOwnerId = await upsertUser("owner-sub"); + + const own = await db + .insert(projects) + .values({ userId: authorId, key: "author-own", displayName: "Author Own" }) + .onConflictDoNothing() + .returning({ id: projects.id }); + ownProjectId = + own[0]?.id ?? + ( + await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.key, "author-own")) + )[0]!.id; + + const shared = await db + .insert(projects) + .values({ userId: projectOwnerId, key: "team-shared", displayName: "Team Shared" }) + .onConflictDoNothing() + .returning({ id: projects.id }); + sharedProjectId = + shared[0]?.id ?? + ( + await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.key, "team-shared")) + )[0]!.id; + + const g = await db + .insert(groups) + .values({ oidcIss: ISS, name: "team" }) + .onConflictDoNothing() + .returning({ id: groups.id }); + sharedGroupId = + g[0]?.id ?? + (await db.select({ id: groups.id }).from(groups).where(eq(groups.name, "team")))[0]! + .id; + + await db + .insert(userGroups) + .values({ userId: authorId, groupId: sharedGroupId }) + .onConflictDoNothing(); + + author = ctxFor(authorId, "author-sub", ["team"]); +}); + +beforeEach(async () => { + await db.delete(memories); + await setShareAccess("rw"); +}); + +afterAll(async () => { + await db.delete(memories); + await pg.end(); +}); + +describe("memory.write", () => { + test("writes into a project the caller owns", async () => { + const res = await toolMap["memory.write"]!.handler( + { content: "hello", scope: "project", project: "author-own" }, + author, + ); + expect(res.isError).toBeFalsy(); + }); + + test("refuses an unknown project rather than creating one", async () => { + const res = await toolMap["memory.write"]!.handler( + { content: "hello", scope: "project", project: "does-not-exist" }, + author, + ); + + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toMatch(/project\.identify/); + }); +}); + +describe("memory.update", () => { + test("updates content and increments version", async () => { + const id = await seedMemory(author.userId, ownProjectId); + const before = await db + .select({ version: memories.version }) + .from(memories) + .where(eq(memories.id, id)); + + const res = await toolMap["memory.update"]!.handler( + { id, content: "revised content" }, + author, + ); + + expect(res.isError).toBeFalsy(); + expect((res.structuredContent as { version: number }).version).toBe( + before[0]!.version + 1, + ); + }); + + test("refuses a stale version", async () => { + const id = await seedMemory(author.userId, ownProjectId); + + const res = await toolMap["memory.update"]!.handler( + { id, content: "revised", version: 99 }, + author, + ); + + expect(res.isError).toBe(true); + }); + + test("denies updating a memory in a project shared read-only", async () => { + const id = await seedMemory(author.userId, sharedProjectId); + await setShareAccess("ro"); + + const res = await toolMap["memory.update"]!.handler( + { id, content: "sneaky edit" }, + author, + ); + + expect(res.isError).toBe(true); + }); +}); + +describe("memory.delete authorization", () => { + test("allows deleting a memory in a project shared read-write", async () => { + const id = await seedMemory(author.userId, sharedProjectId); + + const res = await toolMap["memory.delete"]!.handler({ id }, author); + + expect(res.isError).toBeFalsy(); + expect(await isDeleted(id)).toBe(true); + }); + + test("denies deleting a memory in a project shared read-only, even to its author", async () => { + // The realistic path here: the memory was written while the share was + // rw, then an owner downgraded the group to ro. Authoring the row must + // not grant a standing write privilege the project ACL has revoked — + // memory.update already refuses this, and delete must agree. + const id = await seedMemory(author.userId, sharedProjectId); + await setShareAccess("ro"); + + const res = await toolMap["memory.delete"]!.handler({ id }, author); + + expect(res.isError).toBe(true); + expect(await isDeleted(id)).toBe(false); + }); + + test("denies deleting another user's user-scope memory", async () => { + const id = await seedMemory(projectOwnerId, null); + + const res = await toolMap["memory.delete"]!.handler({ id }, author); + + expect(res.isError).toBe(true); + expect(await isDeleted(id)).toBe(false); + }); +}); diff --git a/apps/web/lib/mcp/tools.ts b/apps/web/lib/mcp/tools.ts index 6e4e8ac..ec6888a 100644 --- a/apps/web/lib/mcp/tools.ts +++ b/apps/web/lib/mcp/tools.ts @@ -11,6 +11,7 @@ import { MemoryIdInput, MemoryDeleteInput, MemoryListInput, + MemoryPatchInput, MemorySearchInput, MemoryUpdateInput, MemoryWriteInput, @@ -20,20 +21,22 @@ import { SnippetListInput, SnippetDeleteInput, } from "@shared-memory/schemas"; -import { embedText } from "@/lib/embedder"; import { searchMemories } from "@/lib/memories"; +import { + createMemory, + patchMemory, + softDeleteMemory, + updateMemory, + type Actor, + type ProjectResolver, +} from "@/lib/memory-mutations"; import { getSnippet, putSnippet, listSnippets, softDeleteSnippet, } from "@/lib/snippets"; -import { - CONCURRENT_EDIT_ERROR, - canWriteProject, - getProjectAccess, - readableProjectIds, -} from "@/lib/access"; +import { getProjectAccess, readableProjectIds } from "@/lib/access"; import type { UserContext } from "./context"; /** @@ -151,6 +154,27 @@ function withDefaultProject( return { ...obj, project: ctx.defaultProjectKey }; } +/** Adapt an MCP request context to the shared mutation layer. */ +function mcpActor(ctx: UserContext): Actor { + return { userId: ctx.userId, groups: ctx.groups, via: "mcp" }; +} + +/** + * Project resolution for MCP writes. Unlike the Web UI, the MCP surface + * never auto-creates a project — an unknown key is an error telling the + * caller to run project.identify first, which keeps agents from silently + * spawning near-miss projects off a typo'd key. + */ +function mcpProjectResolver(ctx: UserContext): ProjectResolver { + return async (key: string) => { + const id = await resolveProjectId(ctx, key); + if (!id) { + return { ok: false, error: `unknown project '${key}'; call project.identify first` }; + } + return { ok: true, value: id }; + }; +} + // ---------- tools ---------- const projectIdentify: ToolDef = { @@ -373,55 +397,18 @@ const memoryWrite: ToolDef = { const parsed = MemoryWriteInput.safeParse(withDefaultProject(args, ctx)); if (!parsed.success) return err(parsed.error.message); - const scope = parsed.data.scope; - let projectId: string | null = null; - let projectKey: string | undefined = undefined; - if (scope === "project") { - projectKey = projectKeyOrDefault(ctx, parsed.data.project); - if (!projectKey) { - return err("scope=project requires `project` key (or X-Project-Key header)"); - } - projectId = await resolveProjectId(ctx, projectKey); - if (!projectId) { - return err(`unknown project '${projectKey}'; call project.identify first`); - } - // Authorize write. Owner always allowed; otherwise require rw. - const allowed = await canWriteProject(ctx.userId, ctx.groups, projectId); - if (!allowed) { - return err(`no write access to project '${projectKey}'`); - } + // Fold the X-Project-Key fallback in before the shared path sees it. + const input = { + ...parsed.data, + project: projectKeyOrDefault(ctx, parsed.data.project), + }; + if (input.scope === "project" && !input.project) { + return err("scope=project requires `project` key (or X-Project-Key header)"); } - // Embed inline so the new memory is searchable immediately. Slower - // writes (~50–150 ms) are an acceptable price for that guarantee; if - // embedder pressure ever forces an async path, only this section - // needs to change. - const embedding = await embedText(parsed.data.content); - - const inserted = await db - .insert(memories) - .values({ - userId: ctx.userId, - projectId, - scope, - content: parsed.data.content, - tags: parsed.data.tags ?? [], - embedding, - lastEditedBy: ctx.userId, - }) - .returning({ id: memories.id, createdAt: memories.createdAt }); - - const m = inserted[0]!; - await db.insert(auditLog).values({ - userId: ctx.userId, - actor: "mcp", - action: "memory.write", - entityType: "memory", - entityId: m.id, - payload: { scope, projectKey: projectKey ?? null, tags: parsed.data.tags ?? [] }, - }); - - return ok({ id: m.id, createdAt: m.createdAt }, `wrote memory ${m.id}`); + const res = await createMemory(mcpActor(ctx), input, mcpProjectResolver(ctx)); + if (!res.ok) return err(res.error); + return ok(res.value, `wrote memory ${res.value.id}`); }, }; @@ -508,8 +495,28 @@ const memoryGet: ToolDef = { const parsed = MemoryIdInput.safeParse(args); if (!parsed.success) return err(parsed.error.message); + // Project explicitly rather than `select()`-ing the raw row. The + // table carries `embedding` (384 floats) and `content_tsv` (the full + // lexeme index, which outgrows `content` itself on large memories) — + // both are Postgres retrieval internals that no MCP client can use, + // and together they were the majority of every response. Returning + // them also pushed large memories past the tool-output cap. This is + // the same 9-field shape memory.list and memory.search return. const row = await db - .select() + .select({ + id: memories.id, + scope: memories.scope, + projectId: memories.projectId, + content: memories.content, + tags: memories.tags, + version: memories.version, + lastEditedBy: memories.lastEditedBy, + createdAt: memories.createdAt, + updatedAt: memories.updatedAt, + // Needed for the authorization check below; stripped before the + // response so the payload matches list/search exactly. + userId: memories.userId, + }) .from(memories) .where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt))) .limit(1); @@ -518,8 +525,8 @@ const memoryGet: ToolDef = { // Authorize read: own row, OR project-scope row in an accessible // project. Anything else looks "not found" to the caller. - const m = row[0]; - if (m.userId !== ctx.userId) { + const { userId, ...m } = row[0]; + if (userId !== ctx.userId) { if (!m.projectId) return err("not found"); const access = await getProjectAccess(ctx.userId, ctx.groups, m.projectId); if (access === null) return err("not found"); @@ -550,57 +557,9 @@ const memoryDelete: ToolDef = { const parsed = MemoryDeleteInput.safeParse(args); if (!parsed.success) return err(parsed.error.message); - // Look up the row first to authorize and capture its current version - // for the CAS. Shared-project writes need a per-project access check. - const target = await db - .select({ - id: memories.id, - userId: memories.userId, - projectId: memories.projectId, - scope: memories.scope, - version: memories.version, - }) - .from(memories) - .where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt))) - .limit(1); - const m = target[0]; - if (!m) return err("not found"); - - if (m.userId !== ctx.userId) { - // Not the owner. User-scope memories can only be deleted by their - // owner; project-scope require rw access on the project. - if (m.scope === "user" || !m.projectId) return err("not found"); - const allowed = await canWriteProject(ctx.userId, ctx.groups, m.projectId); - if (!allowed) return err("no write access to this project"); - } - - // Optimistic-lock CAS: pin to the caller-supplied version when given, - // else the version we just read in this handler. The 0-row response - // tells us a peer raced us. - const expectedVersion = parsed.data.version ?? m.version; - const updated = await db - .update(memories) - .set({ deletedAt: new Date(), lastEditedBy: ctx.userId }) - .where( - and( - eq(memories.id, parsed.data.id), - eq(memories.version, expectedVersion), - isNull(memories.deletedAt), - ), - ) - .returning({ id: memories.id }); - - if (!updated[0]) return err(CONCURRENT_EDIT_ERROR); - - await db.insert(auditLog).values({ - userId: ctx.userId, - actor: "mcp", - action: "memory.delete", - entityType: "memory", - entityId: updated[0].id, - }); - - return ok({ id: updated[0].id, deleted: true }, `deleted memory ${updated[0].id}`); + const res = await softDeleteMemory(mcpActor(ctx), parsed.data); + if (!res.ok) return err(res.error); + return ok({ id: res.value.id, deleted: true }, `deleted memory ${res.value.id}`); }, }; @@ -638,124 +597,51 @@ const memoryUpdate: ToolDef = { const parsed = MemoryUpdateInput.safeParse(withDefaultProject(args, ctx)); if (!parsed.success) return err(parsed.error.message); - const existingRows = await db - .select({ - id: memories.id, - content: memories.content, - scope: memories.scope, - projectId: memories.projectId, - projectKey: projects.key, - version: memories.version, - userId: memories.userId, - }) - .from(memories) - .leftJoin(projects, eq(memories.projectId, projects.id)) - .where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt))) - .limit(1); - const existing = existingRows[0]; - if (!existing) return err("not found"); + const res = await updateMemory(mcpActor(ctx), parsed.data, mcpProjectResolver(ctx)); + if (!res.ok) return err(res.error); + return ok(res.value, `updated memory ${res.value.id}`); + }, +}; - // Authorize write. - if (existing.scope === "user") { - if (existing.userId !== ctx.userId) return err("not found"); - } else if (existing.projectId) { - const allowed = await canWriteProject(ctx.userId, ctx.groups, existing.projectId); - if (!allowed) return err("no write access to this project"); - } +const memoryPatch: ToolDef = { + name: "memory.patch", + description: + "Replace one exact snippet of a memory's content, leaving the rest untouched — the same mental model as editing a file. Use this INSTEAD of memory.update whenever you're making a small edit to a large memory: adding an entry under a heading, correcting a line, updating a status. memory.update requires you to resend the entire document, which risks silently dropping content you didn't mean to touch; memory.patch only needs the fragment you're changing. `old_string` must appear EXACTLY once — if it's missing or ambiguous the call fails and nothing is changed, so include enough surrounding context to make it unique. Pass an empty `new_string` to delete the matched text. Re-embeds automatically, preserves the memory's id, and accepts `version` for the same concurrent-edit protection as memory.update.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + old_string: { + type: "string", + description: + "The exact text to replace. Must occur exactly once in the memory's content — include surrounding lines if the fragment alone would be ambiguous.", + }, + new_string: { + type: "string", + description: + "The replacement text. May be empty to delete the matched text (the memory itself may not be left empty).", + }, + version: { + type: "integer", + minimum: 0, + description: + "Optimistic-locking token from memory.get / memory.list. When supplied, the patch is rejected if the row was edited by someone else since you read it.", + }, + }, + required: ["id", "old_string", "new_string"], + }, + async handler(args, ctx) { + const parsed = MemoryPatchInput.safeParse(args); + if (!parsed.success) return err(parsed.error.message); - const update: Record = { - updatedAt: new Date(), - lastEditedBy: ctx.userId, - version: existing.version + 1, - }; - if (parsed.data.tags !== undefined) update.tags = parsed.data.tags; - if (parsed.data.content !== undefined && parsed.data.content !== existing.content) { - update.content = parsed.data.content; - update.embedding = await embedText(parsed.data.content); - } + const res = await patchMemory(mcpActor(ctx), parsed.data); + if (!res.ok) return err(res.error); - let scopeChanged = false; - let projectChanged = false; - let newProjectKey: string | null = existing.projectKey ?? null; - - if (parsed.data.scope !== undefined) { - if (parsed.data.scope === "user") { - if (existing.scope !== "user") { - update.scope = "user"; - scopeChanged = true; - } - if (existing.projectId !== null) { - update.projectId = null; - projectChanged = true; - newProjectKey = null; - } - } else { - // scope === 'project' — schema refine guarantees `project` is set. - const projectKey = parsed.data.project!; - const projectId = await resolveProjectId(ctx, projectKey); - if (!projectId) { - return err(`unknown project '${projectKey}'; call project.identify first`); - } - // Moving INTO a project requires write access there. - const allowedTarget = await canWriteProject(ctx.userId, ctx.groups, projectId); - if (!allowedTarget) { - return err(`no write access to project '${projectKey}'`); - } - if (existing.scope !== "project") { - update.scope = "project"; - scopeChanged = true; - } - if (existing.projectId !== projectId) { - update.projectId = projectId; - projectChanged = true; - newProjectKey = projectKey; - } - } - } - - const expectedVersion = parsed.data.version ?? existing.version; - const updated = await db - .update(memories) - .set(update) - .where( - and( - eq(memories.id, parsed.data.id), - eq(memories.version, expectedVersion), - ), - ) - .returning({ - id: memories.id, - updatedAt: memories.updatedAt, - version: memories.version, - }); - - if (!updated[0]) return err(CONCURRENT_EDIT_ERROR); - - const auditFields = Object.keys(update).filter( - (k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy", + const { id, delta, contentLength } = res.value; + return ok( + res.value, + `patched memory ${id} (${delta >= 0 ? "+" : ""}${delta} chars, now ${contentLength})`, ); - const auditPayload: Record = { fields: auditFields }; - if (scopeChanged || projectChanged) { - auditPayload.scope = { - from: existing.scope, - to: update.scope ?? existing.scope, - }; - auditPayload.projectKey = { - from: existing.projectKey ?? null, - to: newProjectKey, - }; - } - - await db.insert(auditLog).values({ - userId: ctx.userId, - actor: "mcp", - action: "memory.update", - entityType: "memory", - entityId: updated[0]!.id, - payload: auditPayload, - }); - - return ok(updated[0]!, `updated memory ${updated[0]!.id}`); }, }; @@ -1113,6 +999,7 @@ export const tools: ToolDef[] = [ projectIdentify, memoryWrite, memoryUpdate, + memoryPatch, memoryList, memoryGet, memorySearch, diff --git a/apps/web/lib/memory-actions.ts b/apps/web/lib/memory-actions.ts index 4daae84..e271e3d 100644 --- a/apps/web/lib/memory-actions.ts +++ b/apps/web/lib/memory-actions.ts @@ -2,37 +2,36 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/lib/db/client"; -import { memories, projects, auditLog } from "@/lib/db/schema"; -import { embedText } from "@/lib/embedder"; +import { projects } from "@/lib/db/schema"; import { resolveProjectId, upsertProject } from "@/lib/projects"; import { MemoryWriteInput, MemoryUpdateInput, MemoryDeleteInput, } from "@shared-memory/schemas"; +import { getUserGroupNames, readableProjectIds } from "@/lib/access"; import { - CONCURRENT_EDIT_ERROR, - canWriteProject, - getUserGroupNames, - readableProjectIds, -} from "@/lib/access"; + createMemory, + softDeleteMemory, + updateMemory, + type Actor, + type Outcome, + type ProjectResolver, +} from "@/lib/memory-mutations"; /** - * Server Actions for memory CRUD from the Web UI. Mirrors the MCP tools - * but writes through the same DB layer, so updates and deletes here are - * indistinguishable from those made via Claude Code. + * Server Actions for memory CRUD from the Web UI. + * + * These are thin adapters: form parsing, then `lib/memory-mutations`, + * then revalidate/redirect. The authorize → mutate → re-embed → CAS → + * audit sequence lives in that shared module so this surface and the MCP + * tools cannot drift apart — they previously did, and the sharing rules + * ended up subtly different between them. * * `actor` is "web" in audit_log so we can tell the two paths apart later. - * - * Sharing: project-scope memories may live under projects shared with - * the user's groups. Reads include those projects; writes require the - * user to own the project or have an `rw` share. Cross-user concurrent - * edits use the `version` column for optimistic locking — if the stored - * version no longer matches what the form submitted, we surface - * `CONCURRENT_EDIT_ERROR` rather than clobber. */ async function requireUserId(): Promise { @@ -41,6 +40,52 @@ async function requireUserId(): Promise { return session.user.id; } +/** Server Actions signal failure by throwing; the shared layer returns Outcome. */ +function must(outcome: Outcome): T { + if (!outcome.ok) throw new Error(outcome.error); + return outcome.value; +} + +async function webActor(): Promise<{ actor: Actor; resolveProject: ProjectResolver }> { + const userId = await requireUserId(); + const groups = await getUserGroupNames(userId); + return { + actor: { userId, groups, via: "web" }, + resolveProject: webProjectResolver(userId, groups), + }; +} + +/** + * Project resolution for Web UI writes. Unlike the MCP surface, an + * unknown key is CREATED rather than rejected — a person typing a project + * name into a form means to make one. Shared projects are matched only + * within the set the user can actually read, because `projects.key` is + * unique per user rather than globally: an unscoped key match could + * otherwise select someone else's project. + * + * Write access to whatever this returns is enforced centrally by the + * mutation layer, so it deliberately isn't re-checked here. + */ +function webProjectResolver(userId: string, groupNames: string[]): ProjectResolver { + return async (key: string) => { + const owned = await resolveProjectId(userId, key); + if (owned) return { ok: true, value: owned }; + + const readableIds = await readableProjectIds(userId, groupNames); + const shared = + readableIds.length > 0 + ? await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.key, key), inArray(projects.id, readableIds))) + .limit(1) + : []; + if (shared[0]) return { ok: true, value: shared[0].id }; + + return { ok: true, value: await upsertProject(userId, key) }; + }; +} + function parseTags(raw: FormDataEntryValue | null): string[] { if (typeof raw !== "string") return []; return raw @@ -50,95 +95,26 @@ function parseTags(raw: FormDataEntryValue | null): string[] { } export async function createMemoryAction(formData: FormData) { - const userId = await requireUserId(); - const groupNames = await getUserGroupNames(userId); + const { actor, resolveProject } = await webActor(); - const payload = { + const parsed = MemoryWriteInput.safeParse({ content: String(formData.get("content") ?? "").trim(), scope: (formData.get("scope") as "project" | "user") || "project", project: (formData.get("project") as string | null)?.trim() || undefined, tags: parseTags(formData.get("tags")), - }; - const parsed = MemoryWriteInput.safeParse(payload); + }); if (!parsed.success) { throw new Error(parsed.error.issues.map((i) => i.message).join("; ")); } - let projectId: string | null = null; - if (parsed.data.scope === "project") { - if (!parsed.data.project) throw new Error("scope=project requires `project`"); - // Same priority as memory.update's reclassification path: prefer an - // owned project; otherwise check for a shared one we have rw on; - // otherwise auto-upsert as owner. - const owned = await resolveProjectId(userId, parsed.data.project); - if (owned) { - projectId = owned; - } else { - // Restrict the by-key lookup to projects the user can actually - // read. Without this, a different user's project with the same - // key string could be selected (`projects.key` is unique per user, - // not globally), opening a cross-user write hazard. - const readableIds = await readableProjectIds(userId, groupNames); - const sharedRow = - readableIds.length > 0 - ? await db - .select({ id: projects.id }) - .from(projects) - .where( - and( - eq(projects.key, parsed.data.project), - inArray(projects.id, readableIds), - ), - ) - .limit(1) - : []; - if (sharedRow[0]) { - const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id); - if (!allowed) { - throw new Error(`no write access to project '${parsed.data.project}'`); - } - projectId = sharedRow[0].id; - } else { - projectId = await upsertProject(userId, parsed.data.project); - } - } - } - - const embedding = await embedText(parsed.data.content); - - const inserted = await db - .insert(memories) - .values({ - userId, - projectId, - scope: parsed.data.scope, - content: parsed.data.content, - tags: parsed.data.tags ?? [], - embedding, - lastEditedBy: userId, - }) - .returning({ id: memories.id }); - - await db.insert(auditLog).values({ - userId, - actor: "web", - action: "memory.write", - entityType: "memory", - entityId: inserted[0]!.id, - payload: { - scope: parsed.data.scope, - projectKey: parsed.data.project ?? null, - tags: parsed.data.tags ?? [], - }, - }); + const created = must(await createMemory(actor, parsed.data, resolveProject)); revalidatePath("/memories"); - redirect(`/memories/${inserted[0]!.id}`); + redirect(`/memories/${created.id}`); } export async function updateMemoryAction(formData: FormData) { - const userId = await requireUserId(); - const groupNames = await getUserGroupNames(userId); + const { actor, resolveProject } = await webActor(); const id = String(formData.get("id") ?? ""); const rawScope = formData.get("scope"); @@ -164,153 +140,7 @@ export async function updateMemoryAction(formData: FormData) { throw new Error(parsed.error.issues.map((i) => i.message).join("; ")); } - // Fetch the row regardless of ownership — we may be editing a shared - // memory. Authorization is enforced below against the project, not - // by `user_id`. - const existingRows = await db - .select({ - id: memories.id, - content: memories.content, - scope: memories.scope, - projectId: memories.projectId, - projectKey: projects.key, - version: memories.version, - userId: memories.userId, - }) - .from(memories) - .leftJoin(projects, eq(memories.projectId, projects.id)) - .where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt))) - .limit(1); - const existing = existingRows[0]; - if (!existing) throw new Error("not found"); - - // Authorize write. For user-scope memories, only the owner can edit. - // For project-scope memories, owner OR a group with rw access. - if (existing.scope === "user") { - if (existing.userId !== userId) throw new Error("not found"); - } else if (existing.projectId) { - const allowed = await canWriteProject(userId, groupNames, existing.projectId); - if (!allowed) { - throw new Error("you don't have write access to this project"); - } - } - - const update: Record = { - updatedAt: new Date(), - lastEditedBy: userId, - version: existing.version + 1, - }; - if (parsed.data.tags !== undefined) update.tags = parsed.data.tags; - if (parsed.data.content !== undefined && parsed.data.content !== existing.content) { - update.content = parsed.data.content; - update.embedding = await embedText(parsed.data.content); - } - - let scopeChanged = false; - let projectChanged = false; - let newProjectKey: string | null = existing.projectKey ?? null; - - if (parsed.data.scope !== undefined) { - if (parsed.data.scope === "user") { - if (existing.scope !== "user") { - update.scope = "user"; - scopeChanged = true; - } - if (existing.projectId !== null) { - update.projectId = null; - projectChanged = true; - newProjectKey = null; - } - } else { - // scope === 'project' — schema refine guarantees project is set. - // Moving INTO a project requires write access there. Owners get - // a fresh project upsert; non-owners must target an existing one - // they have rw on. - const projectKey = parsed.data.project!; - let projectId: string; - const existingId = await resolveProjectId(userId, projectKey); - if (existingId) { - projectId = existingId; - } else { - // Restrict the shared-project lookup to projects the user can - // actually read (`projects.key` is unique per user, not globally, - // so an unscoped key match could resolve another user's project). - const readableIds = await readableProjectIds(userId, groupNames); - const sharedRow = - readableIds.length > 0 - ? await db - .select({ id: projects.id }) - .from(projects) - .where( - and(eq(projects.key, projectKey), inArray(projects.id, readableIds)), - ) - .limit(1) - : []; - if (sharedRow[0]) { - const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id); - if (!allowed) { - throw new Error(`no write access to project '${projectKey}'`); - } - projectId = sharedRow[0].id; - } else { - // Auto-upsert as owner — user becomes the project owner of a - // brand-new private project. - projectId = await upsertProject(userId, projectKey); - } - } - if (existing.scope !== "project") { - update.scope = "project"; - scopeChanged = true; - } - if (existing.projectId !== projectId) { - update.projectId = projectId; - projectChanged = true; - newProjectKey = projectKey; - } - } - } - - // Optimistic-locking guard. When `version` is supplied, the UPDATE - // matches on (id, version); a 0-row result means the caller's view - // is stale. When `version` is NOT supplied, we still match on the - // pre-fetched version to keep behaviour deterministic. - const expectedVersion = parsed.data.version ?? existing.version; - const updated = await db - .update(memories) - .set(update) - .where( - and( - eq(memories.id, parsed.data.id), - eq(memories.version, expectedVersion), - ), - ) - .returning({ id: memories.id }); - - if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR); - - const auditFields = Object.keys(update).filter( - (k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy", - ); - const auditPayload: Record = { fields: auditFields }; - if (scopeChanged || projectChanged) { - auditPayload.scope = { - from: existing.scope, - to: update.scope ?? existing.scope, - }; - auditPayload.projectKey = { - from: existing.projectKey ?? null, - to: newProjectKey, - }; - } - - await db.insert(auditLog).values({ - userId, - actor: "web", - action: "memory.update", - entityType: "memory", - entityId: parsed.data.id, - payload: auditPayload, - }); + must(await updateMemory(actor, parsed.data, resolveProject)); revalidatePath(`/memories/${parsed.data.id}`); revalidatePath("/memories"); @@ -318,8 +148,7 @@ export async function updateMemoryAction(formData: FormData) { } export async function deleteMemoryAction(formData: FormData) { - const userId = await requireUserId(); - const groupNames = await getUserGroupNames(userId); + const { actor } = await webActor(); const id = String(formData.get("id") ?? ""); const rawVersion = formData.get("version"); const version = @@ -332,53 +161,7 @@ export async function deleteMemoryAction(formData: FormData) { }); if (!parsed.success) throw new Error(parsed.error.issues[0]!.message); - // Authorize delete: same rule as update — owner OR rw on the project. - const existing = await db - .select({ - id: memories.id, - scope: memories.scope, - projectId: memories.projectId, - userId: memories.userId, - version: memories.version, - }) - .from(memories) - .where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt))) - .limit(1); - const row = existing[0]; - if (!row) throw new Error("not found"); - - if (row.scope === "user") { - if (row.userId !== userId) throw new Error("not found"); - } else if (row.projectId) { - const allowed = await canWriteProject(userId, groupNames, row.projectId); - if (!allowed) throw new Error("you don't have write access to this project"); - } - - // CAS on version so a peer's concurrent edit can't be silently overwritten - // by this delete. Form may or may not supply version; fall back to the row - // we just read to keep behaviour deterministic. - const expectedVersion = parsed.data.version ?? row.version; - const updated = await db - .update(memories) - .set({ deletedAt: new Date(), lastEditedBy: userId }) - .where( - and( - eq(memories.id, parsed.data.id), - eq(memories.version, expectedVersion), - isNull(memories.deletedAt), - ), - ) - .returning({ id: memories.id }); - - if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR); - - await db.insert(auditLog).values({ - userId, - actor: "web", - action: "memory.delete", - entityType: "memory", - entityId: updated[0].id, - }); + must(await softDeleteMemory(actor, parsed.data)); revalidatePath("/memories"); redirect("/memories"); diff --git a/apps/web/lib/memory-mutations.test.ts b/apps/web/lib/memory-mutations.test.ts new file mode 100644 index 0000000..55caf89 --- /dev/null +++ b/apps/web/lib/memory-mutations.test.ts @@ -0,0 +1,263 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; + +/** + * Tests for the shared mutation layer itself — the code both the MCP + * tools and the Web UI Server Actions now route through. + * + * Two things matter here: + * 1. The ProjectResolver seam really is the ONLY behavioural difference + * between the two surfaces. + * 2. The authorization rule is uniform across update / patch / delete. + * It previously wasn't: delete-over-MCP let a row's author bypass the + * project ACL. + */ +vi.mock("@/lib/embedder", () => ({ + embedText: async (text: string) => { + let h = 0; + for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0; + return Array.from({ length: 384 }, (_, i) => ((h + i * 7919) % 1000) / 1000); + }, + embedTexts: async (texts: string[]) => texts.map(() => Array(384).fill(0.1)), + embedderReady: async () => true, + EmbedderError: class extends Error {}, +})); + +const { db, pg } = await import("@/lib/db/client"); +const { memories, projects, users, groups, userGroups, projectShares } = await import( + "@/lib/db/schema" +); +const { createMemory, updateMemory, patchMemory, softDeleteMemory } = await import( + "@/lib/memory-mutations" +); +const { and, eq } = await import("drizzle-orm"); +type Actor = import("@/lib/memory-mutations").Actor; +type ProjectResolver = import("@/lib/memory-mutations").ProjectResolver; + +const ISS = "http://test-mutations"; + +let actor: Actor; +let otherUserId: string; +let sharedProjectId: string; +let sharedGroupId: string; + +/** Mirrors the MCP surface: unknown project keys are refused. */ +const refusingResolver: ProjectResolver = async (key) => ({ + ok: false, + error: `unknown project '${key}'; call project.identify first`, +}); + +/** Mirrors the Web UI surface: unknown project keys are created. */ +function creatingResolver(userId: string): ProjectResolver { + return async (key) => { + const existing = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.key, key), eq(projects.userId, userId))) + .limit(1); + if (existing[0]) return { ok: true, value: existing[0].id }; + const created = await db + .insert(projects) + .values({ userId, key, displayName: key }) + .returning({ id: projects.id }); + return { ok: true, value: created[0]!.id }; + }; +} + +async function seedMemory(userId: string, projectId: string | null): Promise { + const r = await db + .insert(memories) + .values({ + userId, + projectId, + scope: projectId ? "project" : "user", + content: "line one\nline two\n", + tags: [], + embedding: Array(384).fill(0.5), + lastEditedBy: userId, + }) + .returning({ id: memories.id }); + return r[0]!.id; +} + +async function setShareAccess(access: "ro" | "rw") { + await db + .insert(projectShares) + .values({ projectId: sharedProjectId, groupId: sharedGroupId, access }) + .onConflictDoUpdate({ + target: [projectShares.projectId, projectShares.groupId], + set: { access }, + }); +} + +beforeAll(async () => { + const me = await db + .insert(users) + .values({ oidcSub: "mut-me", oidcIss: ISS }) + .onConflictDoNothing() + .returning({ id: users.id }); + const myId = + me[0]?.id ?? + ( + await db.select({ id: users.id }).from(users).where(eq(users.oidcSub, "mut-me")) + )[0]!.id; + + const other = await db + .insert(users) + .values({ oidcSub: "mut-other", oidcIss: ISS }) + .onConflictDoNothing() + .returning({ id: users.id }); + otherUserId = + other[0]?.id ?? + ( + await db.select({ id: users.id }).from(users).where(eq(users.oidcSub, "mut-other")) + )[0]!.id; + + const p = await db + .insert(projects) + .values({ userId: otherUserId, key: "mut-shared", displayName: "Shared" }) + .onConflictDoNothing() + .returning({ id: projects.id }); + sharedProjectId = + p[0]?.id ?? + ( + await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.key, "mut-shared")) + )[0]!.id; + + const g = await db + .insert(groups) + .values({ oidcIss: ISS, name: "mut-team" }) + .onConflictDoNothing() + .returning({ id: groups.id }); + sharedGroupId = + g[0]?.id ?? + ( + await db.select({ id: groups.id }).from(groups).where(eq(groups.name, "mut-team")) + )[0]!.id; + + await db + .insert(userGroups) + .values({ userId: myId, groupId: sharedGroupId }) + .onConflictDoNothing(); + + actor = { userId: myId, groups: ["mut-team"], via: "mcp" }; +}); + +beforeEach(async () => { + await db.delete(memories); + await setShareAccess("rw"); +}); + +afterAll(async () => { + await db.delete(memories); + await pg.end(); +}); + +describe("the ProjectResolver seam", () => { + test("a refusing resolver rejects an unknown project without creating one", async () => { + const res = await createMemory( + actor, + { content: "x", scope: "project", project: "brand-new-key", tags: [] }, + refusingResolver, + ); + + expect(res.ok).toBe(false); + const rows = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.key, "brand-new-key")); + expect(rows).toHaveLength(0); + }); + + test("a creating resolver makes the project and writes into it", async () => { + const res = await createMemory( + actor, + { content: "x", scope: "project", project: "made-on-demand", tags: [] }, + creatingResolver(actor.userId), + ); + + expect(res.ok).toBe(true); + const rows = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.key, "made-on-demand")); + expect(rows).toHaveLength(1); + }); +}); + +describe("authorization is uniform across mutations", () => { + // Each of these seeds a memory the actor AUTHORED, then downgrades the + // share to read-only. Authoring must not survive as a write privilege. + test("update is denied on a read-only share", async () => { + const id = await seedMemory(actor.userId, sharedProjectId); + await setShareAccess("ro"); + + const res = await updateMemory(actor, { id, content: "edited" }, refusingResolver); + + expect(res.ok).toBe(false); + }); + + test("patch is denied on a read-only share", async () => { + const id = await seedMemory(actor.userId, sharedProjectId); + await setShareAccess("ro"); + + const res = await patchMemory(actor, { + id, + old_string: "line one", + new_string: "line uno", + }); + + expect(res.ok).toBe(false); + }); + + test("delete is denied on a read-only share", async () => { + const id = await seedMemory(actor.userId, sharedProjectId); + await setShareAccess("ro"); + + const res = await softDeleteMemory(actor, { id }); + + expect(res.ok).toBe(false); + }); + + test("all three are allowed again once the share is read-write", async () => { + const id = await seedMemory(actor.userId, sharedProjectId); + + expect((await updateMemory(actor, { id, content: "a\nb\n" }, refusingResolver)).ok).toBe( + true, + ); + expect((await patchMemory(actor, { id, old_string: "a", new_string: "c" })).ok).toBe( + true, + ); + expect((await softDeleteMemory(actor, { id })).ok).toBe(true); + }); + + test("another user's user-scope memory is invisible to all three", async () => { + const id = await seedMemory(otherUserId, null); + + expect((await updateMemory(actor, { id, content: "x" }, refusingResolver)).ok).toBe( + false, + ); + expect( + (await patchMemory(actor, { id, old_string: "line one", new_string: "y" })).ok, + ).toBe(false); + expect((await softDeleteMemory(actor, { id })).ok).toBe(false); + }); +}); + +describe("audit trail records the originating surface", () => { + test("via: 'web' and via: 'mcp' are both preserved", async () => { + const webRes = await createMemory( + { ...actor, via: "web" }, + { content: "from the web", scope: "user", tags: [] }, + refusingResolver, + ); + expect(webRes.ok).toBe(true); + + const rows = await pg<{ actor: string }[]>` + SELECT actor FROM audit_log WHERE action = 'memory.write' ORDER BY created_at DESC LIMIT 1 + `; + expect(rows[0]!.actor).toBe("web"); + }); +}); diff --git a/apps/web/lib/memory-mutations.ts b/apps/web/lib/memory-mutations.ts new file mode 100644 index 0000000..93a3124 --- /dev/null +++ b/apps/web/lib/memory-mutations.ts @@ -0,0 +1,357 @@ +import { and, eq, isNull } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import { memories, projects, auditLog } from "@/lib/db/schema"; +import { embedText } from "@/lib/embedder"; +import { applyPatch } from "@/lib/memory-patch"; +import { CONCURRENT_EDIT_ERROR, canWriteProject } from "@/lib/access"; +import type { + MemoryDeleteInput, + MemoryPatchInput, + MemoryUpdateInput, + MemoryWriteInput, +} from "@shared-memory/schemas"; + +/** + * The single write path for memories. + * + * Both surfaces — the MCP tools and the Web UI Server Actions — used to + * reimplement authorize → mutate → re-embed → CAS → audit independently. + * They drifted: `memory.delete` over MCP skipped the project ACL whenever + * the caller happened to author the row, which `memory.update` and the + * whole Web UI did not. Consolidating here is what keeps those rules in + * one place, so a change to the sharing model can't be half-applied. + * + * Callers keep their own presentation concerns: MCP maps Outcome to a + * ToolResult, the Web UI throws and then revalidates/redirects. + */ + +export interface Actor { + userId: string; + /** Group names, for project-share authorization. */ + groups: string[]; + /** Recorded as audit_log.actor so the two surfaces stay distinguishable. */ + via: "web" | "mcp"; +} + +export type Outcome = { ok: true; value: T } | { ok: false; error: string }; + +const fail = (error: string): Outcome => ({ ok: false, error }); +const succeed = (value: T): Outcome => ({ ok: true, value }); + +/** + * Resolves a project key to an id for a write. Injected because this is + * the one place the two surfaces genuinely, deliberately differ: MCP + * refuses unknown projects (the caller is expected to run project.identify + * first), while the Web UI creates one owned by the user. Everything else + * about a write is identical. + */ +export type ProjectResolver = (key: string) => Promise>; + +interface WriteTarget { + scope: "project" | "user"; + projectId: string | null; + userId: string; +} + +/** + * The authorization rule for every mutating operation: + * - user-scope → only the owner may write (anything else reads as 404) + * - project-scope → owner of the project, or a group with `rw` + * + * Authoring a row grants nothing on its own. A memory you wrote while a + * share was `rw` becomes read-only to you when an owner downgrades that + * share to `ro` — the project ACL is the authority, not the byline. + */ +async function authorizeWrite(actor: Actor, row: WriteTarget): Promise> { + if (row.scope === "user") { + return row.userId === actor.userId ? succeed(null) : fail("not found"); + } + if (row.projectId) { + const allowed = await canWriteProject(actor.userId, actor.groups, row.projectId); + if (!allowed) return fail("no write access to this project"); + } + return succeed(null); +} + +export async function createMemory( + actor: Actor, + input: MemoryWriteInput, + resolveProject: ProjectResolver, +): Promise> { + let projectId: string | null = null; + const projectKey = input.scope === "project" ? input.project : undefined; + + if (input.scope === "project") { + if (!projectKey) return fail("scope=project requires `project`"); + const resolved = await resolveProject(projectKey); + if (!resolved.ok) return resolved; + projectId = resolved.value; + const allowed = await canWriteProject(actor.userId, actor.groups, projectId); + if (!allowed) return fail(`no write access to project '${projectKey}'`); + } + + // Embed inline so the new memory is searchable immediately. Slower + // writes (~50–150 ms) are an acceptable price for that guarantee. + const embedding = await embedText(input.content); + + const inserted = await db + .insert(memories) + .values({ + userId: actor.userId, + projectId, + scope: input.scope, + content: input.content, + tags: input.tags ?? [], + embedding, + lastEditedBy: actor.userId, + }) + .returning({ id: memories.id, createdAt: memories.createdAt }); + + const row = inserted[0]!; + await db.insert(auditLog).values({ + userId: actor.userId, + actor: actor.via, + action: "memory.write", + entityType: "memory", + entityId: row.id, + payload: { + scope: input.scope, + projectKey: projectKey ?? null, + tags: input.tags ?? [], + }, + }); + + return succeed(row); +} + +export interface MutatedMemory { + id: string; + updatedAt: Date; + version: number; +} + +export async function updateMemory( + actor: Actor, + input: MemoryUpdateInput, + resolveProject: ProjectResolver, +): Promise> { + const existingRows = await db + .select({ + id: memories.id, + content: memories.content, + scope: memories.scope, + projectId: memories.projectId, + projectKey: projects.key, + version: memories.version, + userId: memories.userId, + }) + .from(memories) + .leftJoin(projects, eq(memories.projectId, projects.id)) + .where(and(eq(memories.id, input.id), isNull(memories.deletedAt))) + .limit(1); + const existing = existingRows[0]; + if (!existing) return fail("not found"); + + const authorized = await authorizeWrite(actor, existing); + if (!authorized.ok) return authorized; + + const update: Record = { + updatedAt: new Date(), + lastEditedBy: actor.userId, + version: existing.version + 1, + }; + if (input.tags !== undefined) update.tags = input.tags; + if (input.content !== undefined && input.content !== existing.content) { + update.content = input.content; + update.embedding = await embedText(input.content); + } + + let scopeChanged = false; + let projectChanged = false; + let newProjectKey: string | null = existing.projectKey ?? null; + + if (input.scope !== undefined) { + if (input.scope === "user") { + if (existing.scope !== "user") { + update.scope = "user"; + scopeChanged = true; + } + if (existing.projectId !== null) { + update.projectId = null; + projectChanged = true; + newProjectKey = null; + } + } else { + // scope === 'project' — the schema refine guarantees `project` is set. + // Moving INTO a project requires write access there. + const projectKey = input.project!; + const resolved = await resolveProject(projectKey); + if (!resolved.ok) return resolved; + const targetId = resolved.value; + const allowed = await canWriteProject(actor.userId, actor.groups, targetId); + if (!allowed) return fail(`no write access to project '${projectKey}'`); + + if (existing.scope !== "project") { + update.scope = "project"; + scopeChanged = true; + } + if (existing.projectId !== targetId) { + update.projectId = targetId; + projectChanged = true; + newProjectKey = projectKey; + } + } + } + + const updated = await casUpdate(input.id, update, input.version ?? existing.version); + if (!updated) return fail(CONCURRENT_EDIT_ERROR); + + const auditFields = Object.keys(update).filter( + (k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy", + ); + const auditPayload: Record = { fields: auditFields }; + if (scopeChanged || projectChanged) { + auditPayload.scope = { from: existing.scope, to: update.scope ?? existing.scope }; + auditPayload.projectKey = { from: existing.projectKey ?? null, to: newProjectKey }; + } + + await db.insert(auditLog).values({ + userId: actor.userId, + actor: actor.via, + action: "memory.update", + entityType: "memory", + entityId: updated.id, + payload: auditPayload, + }); + + return succeed(updated); +} + +export async function patchMemory( + actor: Actor, + input: MemoryPatchInput, +): Promise> { + const existingRows = await db + .select({ + content: memories.content, + scope: memories.scope, + projectId: memories.projectId, + version: memories.version, + userId: memories.userId, + }) + .from(memories) + .where(and(eq(memories.id, input.id), isNull(memories.deletedAt))) + .limit(1); + const existing = existingRows[0]; + if (!existing) return fail("not found"); + + const authorized = await authorizeWrite(actor, existing); + if (!authorized.ok) return authorized; + + const patch = applyPatch(existing.content, input.old_string, input.new_string); + if (!patch.ok) return fail(patch.error); + + const updated = await casUpdate( + input.id, + { + content: patch.content, + embedding: await embedText(patch.content), + updatedAt: new Date(), + lastEditedBy: actor.userId, + version: existing.version + 1, + }, + input.version ?? existing.version, + ); + if (!updated) return fail(CONCURRENT_EDIT_ERROR); + + await db.insert(auditLog).values({ + userId: actor.userId, + actor: actor.via, + action: "memory.patch", + entityType: "memory", + entityId: updated.id, + payload: { + fields: ["content"], + patch: { + offset: existing.content.indexOf(input.old_string), + removed: input.old_string.length, + added: input.new_string.length, + }, + }, + }); + + return succeed({ + ...updated, + contentLength: patch.content.length, + delta: patch.content.length - existing.content.length, + }); +} + +export async function softDeleteMemory( + actor: Actor, + input: MemoryDeleteInput, +): Promise> { + const rows = await db + .select({ + id: memories.id, + userId: memories.userId, + projectId: memories.projectId, + scope: memories.scope, + version: memories.version, + }) + .from(memories) + .where(and(eq(memories.id, input.id), isNull(memories.deletedAt))) + .limit(1); + const existing = rows[0]; + if (!existing) return fail("not found"); + + const authorized = await authorizeWrite(actor, existing); + if (!authorized.ok) return authorized; + + const updated = await db + .update(memories) + .set({ deletedAt: new Date(), lastEditedBy: actor.userId }) + .where( + and( + eq(memories.id, input.id), + eq(memories.version, input.version ?? existing.version), + isNull(memories.deletedAt), + ), + ) + .returning({ id: memories.id }); + + if (!updated[0]) return fail(CONCURRENT_EDIT_ERROR); + + await db.insert(auditLog).values({ + userId: actor.userId, + actor: actor.via, + action: "memory.delete", + entityType: "memory", + entityId: updated[0].id, + }); + + return succeed(updated[0]); +} + +/** + * Compare-and-set on `version`. A zero-row result means a peer edited the + * row between our read and this write. Callers that omit an explicit + * version pass the one they just read, which still closes the read- + * modify-write window inside a single handler. + */ +async function casUpdate( + id: string, + update: Record, + expectedVersion: number, +): Promise { + const rows = await db + .update(memories) + .set(update) + .where(and(eq(memories.id, id), eq(memories.version, expectedVersion))) + .returning({ + id: memories.id, + updatedAt: memories.updatedAt, + version: memories.version, + }); + return rows[0] ?? null; +} diff --git a/apps/web/lib/memory-patch.test.ts b/apps/web/lib/memory-patch.test.ts new file mode 100644 index 0000000..769e3a1 --- /dev/null +++ b/apps/web/lib/memory-patch.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "vitest"; +import { applyPatch } from "@/lib/memory-patch"; + +describe("applyPatch", () => { + test("replaces an old_string that occurs exactly once", () => { + const result = applyPatch("alpha beta gamma", "beta", "BETA"); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.content).toBe("alpha BETA gamma"); + }); + + test("refuses when old_string is absent, rather than silently doing nothing", () => { + const result = applyPatch("alpha beta gamma", "delta", "DELTA"); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/not found/i); + }); + + test("refuses when old_string is ambiguous, and reports the match count", () => { + const result = applyPatch("x marks the spot, x marks it twice", "x", "y"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/2/); + expect(result.error).toMatch(/match/i); + } + }); + + test("treats old_string literally, not as a regular expression", () => { + // A naive RegExp implementation would match "axb" here. + const result = applyPatch("axb and a.b", "a.b", "REPLACED"); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.content).toBe("axb and REPLACED"); + }); + + test("replaces a multi-line old_string, preserving surrounding text", () => { + const content = "## HEADING\n- one\n- two\n\n## OTHER\n"; + const result = applyPatch(content, "## HEADING\n- one", "## HEADING\n- zero\n- one"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.content).toBe("## HEADING\n- zero\n- one\n- two\n\n## OTHER\n"); + } + }); + + test("rejects a patch whose result would exceed the 64,000-char content limit", () => { + // The anchor must be unique, or the ambiguity check fires first and + // this stops testing the length limit at all. + const content = "A".repeat(63_950) + "ANCHOR"; + const result = applyPatch(content, "ANCHOR", "B".repeat(100)); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/64,?000|limit/i); + }); + + test("rejects a no-op patch where new_string equals old_string", () => { + const result = applyPatch("alpha beta", "beta", "beta"); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/identical|no-op|unchanged/i); + }); + + test("allows a patch that deletes text by replacing with an empty string", () => { + const result = applyPatch("keep this, drop this", ", drop this", ""); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.content).toBe("keep this"); + }); + + test("rejects a patch that would empty the memory entirely", () => { + const result = applyPatch("all of it", "all of it", ""); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/empty/i); + }); +}); diff --git a/apps/web/lib/memory-patch.ts b/apps/web/lib/memory-patch.ts new file mode 100644 index 0000000..4346000 --- /dev/null +++ b/apps/web/lib/memory-patch.ts @@ -0,0 +1,76 @@ +import { MEMORY_CONTENT_MAX } from "@shared-memory/schemas"; + +/** + * Pure string-level semantics for `memory.patch`. + * + * Kept free of any DB or auth dependency so both the MCP tool handler and + * the Web UI can share it, and so the refuse-rather-than-clobber rules + * below are directly testable. + * + * The contract mirrors the file-editing primitive coding agents already + * use: an `old_string` that is absent or ambiguous is an ERROR, never a + * silent no-op and never an arbitrary pick. That refusal is the property + * that makes the operation safe to hand to an agent editing a shared + * document it cannot afford to corrupt. + */ +export type PatchOutcome = + | { ok: true; content: string } + | { ok: false; error: string }; + +function countOccurrences(haystack: string, needle: string): number { + let count = 0; + let from = 0; + for (;;) { + const at = haystack.indexOf(needle, from); + if (at === -1) return count; + count += 1; + // Advance past this match so overlapping matches aren't double-counted. + from = at + needle.length; + } +} + +export function applyPatch( + content: string, + oldString: string, + newString: string, +): PatchOutcome { + if (oldString === newString) { + return { + ok: false, + error: "old_string and new_string are identical; the patch would change nothing", + }; + } + + const first = content.indexOf(oldString); + if (first === -1) { + return { + ok: false, + error: + "old_string not found in the memory content; nothing was changed. Fetch the memory with memory.get and copy the exact text you mean to replace.", + }; + } + + // Only pay for a full count once we know there's more than one match. + if (content.indexOf(oldString, first + oldString.length) !== -1) { + const count = countOccurrences(content, oldString); + return { + ok: false, + error: `old_string matches ${count} times; it must match exactly once. Nothing was changed — include more surrounding context to identify the one you mean.`, + }; + } + + const patched = + content.slice(0, first) + newString + content.slice(first + oldString.length); + + if (patched.length === 0) { + return { ok: false, error: "the patch would leave the memory empty" }; + } + if (patched.length > MEMORY_CONTENT_MAX) { + return { + ok: false, + error: `the patched content would be ${patched.length.toLocaleString("en-US")} characters, over the ${MEMORY_CONTENT_MAX.toLocaleString("en-US")}-character limit`, + }; + } + + return { ok: true, content: patched }; +} diff --git a/apps/web/package.json b/apps/web/package.json index 158f5a6..c5960dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,9 @@ "dev": "next dev --port 3000", "build": "next build", "start": "next start --port 3000", - "lint": "next lint", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest", "typecheck": "tsc --noEmit", "db:generate": "drizzle-kit generate", "db:migrate": "tsx ./scripts/migrate.ts", @@ -26,16 +28,18 @@ "zod": "^3.23.8" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.6", + "@tailwindcss/postcss": "^4.0.0", "@types/node": "^22.10.2", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", - "@tailwindcss/postcss": "^4.0.0", "drizzle-kit": "^0.30.1", "esbuild": "^0.24.2", "eslint": "^9.17.0", "eslint-config-next": "^15.1.0", "tailwindcss": "^4.0.0", "tsx": "^4.19.2", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^2" } } diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs index c2ddf74..61e3684 100644 --- a/apps/web/postcss.config.mjs +++ b/apps/web/postcss.config.mjs @@ -1,5 +1,7 @@ -export default { +const config = { plugins: { "@tailwindcss/postcss": {}, }, }; + +export default config; diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..a09ee41 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./", import.meta.url)), + }, + }, + test: { + environment: "node", + include: ["lib/**/*.test.ts", "app/**/*.test.ts"], + setupFiles: ["./vitest.setup.ts"], + // Integration tests share one Postgres database; running files in + // parallel would let them clobber each other's rows. + fileParallelism: false, + }, +}); diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts new file mode 100644 index 0000000..0bf49b6 --- /dev/null +++ b/apps/web/vitest.setup.ts @@ -0,0 +1,25 @@ +/** + * Test environment. `lib/env.ts` validates a full production config at + * import time, so integration tests that touch the DB need these set + * before any module under test is loaded. + * + * Only DATABASE_URL points at anything real — a throwaway Postgres with + * pgvector. The OIDC/secret values exist purely to satisfy validation; + * tests construct a UserContext directly rather than going through auth. + */ +// NODE_ENV is set to "test" by vitest itself. +// +// DATABASE_URL points at a throwaway pgvector instance. The default assumes +// the published port is reachable on localhost; when the test runner is +// itself inside a container, export DATABASE_URL with the database +// container's address instead. See README → Running the tests. +process.env.DATABASE_URL ??= "postgres://test:test@127.0.0.1:55432/shared_memory_test"; +process.env.PUBLIC_URL ??= "http://localhost:3000"; +process.env.OIDC_ISSUER ??= "http://localhost:9000/application/o/test/"; +process.env.OIDC_CLIENT_ID_WEB ??= "test-web"; +process.env.OIDC_CLIENT_SECRET_WEB ??= "test-web-secret"; +process.env.OIDC_CLIENT_ID_MCP ??= "test-mcp"; +process.env.OIDC_AUDIENCE ??= "test-audience"; +process.env.EMBEDDER_URL ??= "http://localhost:8080"; +process.env.NEXTAUTH_SECRET ??= "test-nextauth-secret-at-least-32-chars-long"; +process.env.CLI_TOKEN_SECRET ??= "test-cli-token-secret-at-least-32-chars-long"; diff --git a/package.json b/package.json index e4f656e..2c29501 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "pnpm -r build", "dev": "pnpm --filter @shared-memory/web dev", "lint": "pnpm -r lint", + "test": "pnpm -r test", "typecheck": "pnpm -r typecheck", "db:generate": "pnpm --filter @shared-memory/web db:generate", "db:migrate": "pnpm --filter @shared-memory/web db:migrate" diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 8b9a96c..b0c8638 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -18,7 +18,8 @@ export const ProjectKey = z .regex(/^[a-zA-Z0-9._\-/]+$/, "project key may only contain alphanumerics, ._-/"); export type ProjectKey = z.infer; -export const MemoryContent = z.string().min(1).max(64_000); +export const MEMORY_CONTENT_MAX = 64_000; +export const MemoryContent = z.string().min(1).max(MEMORY_CONTENT_MAX); export const Tags = z .array(z.string().min(1).max(64).regex(/^[a-zA-Z0-9._\-]+$/, "tag must be alphanumeric ._-")) @@ -84,6 +85,19 @@ export const MemoryUpdateInput = z.object({ }); export type MemoryUpdateInput = z.infer; +// memory.patch replaces ONE exact occurrence of `old_string`. Absent or +// ambiguous matches are errors, never silent no-ops — see applyPatch. +// `new_string` may be empty (a deletion); the resulting content still has +// to satisfy MemoryContent, which is checked after the patch is applied. +export const MemoryPatchInput = z.object({ + id: z.string().uuid(), + old_string: z.string().min(1).max(MEMORY_CONTENT_MAX), + new_string: z.string().max(MEMORY_CONTENT_MAX), + // Same optimistic-locking token as memory.update. + version: z.number().int().nonnegative().optional(), +}); +export type MemoryPatchInput = z.infer; + export const MemorySearchInput = z.object({ query: z.string().min(1).max(2000), project: ProjectKey.optional(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88e4d7e..f683843 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: specifier: ^3.23.8 version: 3.25.76 devDependencies: + '@eslint/eslintrc': + specifier: ^3.3.6 + version: 3.3.6 '@tailwindcss/postcss': specifier: ^4.0.0 version: 4.3.0 @@ -93,6 +96,9 @@ importers: typescript: specifier: ^5.7.2 version: 5.9.3 + vitest: + specifier: ^2 + version: 2.1.9(@types/node@22.19.19)(lightningcss@1.32.0) packages/schemas: dependencies: @@ -150,6 +156,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.24.2': resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} engines: {node: '>=18'} @@ -174,6 +186,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.24.2': resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} engines: {node: '>=18'} @@ -198,6 +216,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.24.2': resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} engines: {node: '>=18'} @@ -222,6 +246,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.24.2': resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} engines: {node: '>=18'} @@ -246,6 +276,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.24.2': resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} engines: {node: '>=18'} @@ -270,6 +306,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.24.2': resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} engines: {node: '>=18'} @@ -294,6 +336,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.24.2': resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} engines: {node: '>=18'} @@ -318,6 +366,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.24.2': resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} engines: {node: '>=18'} @@ -342,6 +396,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.24.2': resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} engines: {node: '>=18'} @@ -366,6 +426,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.24.2': resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} engines: {node: '>=18'} @@ -390,6 +456,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.24.2': resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} engines: {node: '>=18'} @@ -414,6 +486,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.24.2': resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} engines: {node: '>=18'} @@ -438,6 +516,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.24.2': resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} engines: {node: '>=18'} @@ -462,6 +546,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.24.2': resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} engines: {node: '>=18'} @@ -486,6 +576,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.24.2': resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} engines: {node: '>=18'} @@ -510,6 +606,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.24.2': resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} engines: {node: '>=18'} @@ -534,6 +636,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.24.2': resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} engines: {node: '>=18'} @@ -570,6 +678,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.24.2': resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} engines: {node: '>=18'} @@ -606,6 +720,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.24.2': resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} engines: {node: '>=18'} @@ -636,6 +756,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.24.2': resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} engines: {node: '>=18'} @@ -660,6 +786,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.24.2': resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} engines: {node: '>=18'} @@ -684,6 +816,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.24.2': resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} engines: {node: '>=18'} @@ -708,6 +846,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.24.2': resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} engines: {node: '>=18'} @@ -742,8 +886,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.4': @@ -969,6 +1113,12 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1081,6 +1231,131 @@ packages: '@protobufjs/utf8@1.1.1': resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1361,6 +1636,35 @@ packages: cpu: [x64] os: [win32] + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@xenova/transformers@2.17.2': resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==} @@ -1438,6 +1742,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -1551,6 +1859,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1570,10 +1882,18 @@ packages: caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -1672,6 +1992,10 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1840,6 +2164,9 @@ packages: resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1871,6 +2198,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.24.2: resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} engines: {node: '>=18'} @@ -2004,6 +2336,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2027,6 +2362,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -2409,8 +2748,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true json-buffer@3.0.1: @@ -2540,6 +2879,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2756,6 +3098,13 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2925,6 +3274,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -3035,6 +3389,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -3065,10 +3422,16 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3165,10 +3528,28 @@ packages: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3254,6 +3635,67 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3280,6 +3722,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3344,6 +3791,9 @@ snapshots: '@esbuild/aix-ppc64@0.19.12': optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.24.2': optional: true @@ -3356,6 +3806,9 @@ snapshots: '@esbuild/android-arm64@0.19.12': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.24.2': optional: true @@ -3368,6 +3821,9 @@ snapshots: '@esbuild/android-arm@0.19.12': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.24.2': optional: true @@ -3380,6 +3836,9 @@ snapshots: '@esbuild/android-x64@0.19.12': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.24.2': optional: true @@ -3392,6 +3851,9 @@ snapshots: '@esbuild/darwin-arm64@0.19.12': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.24.2': optional: true @@ -3404,6 +3866,9 @@ snapshots: '@esbuild/darwin-x64@0.19.12': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.24.2': optional: true @@ -3416,6 +3881,9 @@ snapshots: '@esbuild/freebsd-arm64@0.19.12': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.24.2': optional: true @@ -3428,6 +3896,9 @@ snapshots: '@esbuild/freebsd-x64@0.19.12': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.24.2': optional: true @@ -3440,6 +3911,9 @@ snapshots: '@esbuild/linux-arm64@0.19.12': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.24.2': optional: true @@ -3452,6 +3926,9 @@ snapshots: '@esbuild/linux-arm@0.19.12': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.24.2': optional: true @@ -3464,6 +3941,9 @@ snapshots: '@esbuild/linux-ia32@0.19.12': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.24.2': optional: true @@ -3476,6 +3956,9 @@ snapshots: '@esbuild/linux-loong64@0.19.12': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.24.2': optional: true @@ -3488,6 +3971,9 @@ snapshots: '@esbuild/linux-mips64el@0.19.12': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.24.2': optional: true @@ -3500,6 +3986,9 @@ snapshots: '@esbuild/linux-ppc64@0.19.12': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.24.2': optional: true @@ -3512,6 +4001,9 @@ snapshots: '@esbuild/linux-riscv64@0.19.12': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.24.2': optional: true @@ -3524,6 +4016,9 @@ snapshots: '@esbuild/linux-s390x@0.19.12': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.24.2': optional: true @@ -3536,6 +4031,9 @@ snapshots: '@esbuild/linux-x64@0.19.12': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.24.2': optional: true @@ -3554,6 +4052,9 @@ snapshots: '@esbuild/netbsd-x64@0.19.12': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.24.2': optional: true @@ -3572,6 +4073,9 @@ snapshots: '@esbuild/openbsd-x64@0.19.12': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.24.2': optional: true @@ -3587,6 +4091,9 @@ snapshots: '@esbuild/sunos-x64@0.19.12': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.24.2': optional: true @@ -3599,6 +4106,9 @@ snapshots: '@esbuild/win32-arm64@0.19.12': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.24.2': optional: true @@ -3611,6 +4121,9 @@ snapshots: '@esbuild/win32-ia32@0.19.12': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.24.2': optional: true @@ -3623,6 +4136,9 @@ snapshots: '@esbuild/win32-x64@0.19.12': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.24.2': optional: true @@ -3652,7 +4168,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.6': dependencies: ajv: 6.15.0 debug: 4.4.3 @@ -3660,7 +4176,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3858,6 +4374,9 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 @@ -3938,6 +4457,81 @@ snapshots: '@protobufjs/utf8@1.1.1': {} + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.16.1': {} @@ -4192,6 +4786,46 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.19)(lightningcss@1.32.0) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@xenova/transformers@2.17.2': dependencies: '@huggingface/jinja': 0.2.2 @@ -4310,6 +4944,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -4411,6 +5047,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4432,11 +5070,21 @@ snapshots: caniuse-lite@1.0.30001792: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + chownr@1.1.4: {} client-only@0.0.1: {} @@ -4518,6 +5166,8 @@ snapshots: dependencies: mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -4663,6 +5313,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4742,6 +5394,32 @@ snapshots: '@esbuild/win32-ia32': 0.19.12 '@esbuild/win32-x64': 0.19.12 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.24.2: optionalDependencies: '@esbuild/aix-ppc64': 0.24.2 @@ -4949,7 +5627,7 @@ snapshots: '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 + '@eslint/eslintrc': 3.3.6 '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -4999,6 +5677,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} etag@1.8.1: {} @@ -5017,6 +5699,8 @@ snapshots: expand-template@2.0.3: {} + expect-type@1.4.0: {} + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -5447,7 +6131,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -5558,6 +6242,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5760,6 +6446,10 @@ snapshots: path-to-regexp@8.4.2: {} + pathe@1.1.2: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5954,6 +6644,38 @@ snapshots: rfdc@1.4.1: {} + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -6139,6 +6861,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -6168,8 +6892,12 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -6314,11 +7042,21 @@ snapshots: dependencies: real-require: 1.0.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6438,6 +7176,69 @@ snapshots: vary@1.1.2: {} + vite-node@2.1.9(@types/node@22.19.19)(lightningcss@1.32.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.19)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.19)(lightningcss@1.32.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.14 + rollup: 4.62.4 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + lightningcss: 1.32.0 + + vitest@2.1.9(@types/node@22.19.19)(lightningcss@1.32.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)(lightningcss@1.32.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.19)(lightningcss@1.32.0) + vite-node: 2.1.9(@types/node@22.19.19)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -6487,6 +7288,11 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrappy@1.0.2: {}