diff --git a/apps/web/app/(authed)/dashboard/page.tsx b/apps/web/app/(authed)/dashboard/page.tsx index f6b5f15..efb2dab 100644 --- a/apps/web/app/(authed)/dashboard/page.tsx +++ b/apps/web/app/(authed)/dashboard/page.tsx @@ -1,8 +1,9 @@ import Link from "next/link"; -import { and, desc, eq, isNull, sql, count } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, or, sql, count } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/lib/db/client"; -import { memories, projects } from "@/lib/db/schema"; +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 { Badge } from "@/app/_components/ui/badge"; @@ -14,14 +15,27 @@ export const dynamic = "force-dynamic"; export default async function DashboardPage() { const session = await auth(); const userId = session!.user.id; + const groupNames = await getUserGroupNames(userId); + // Visibility widening — recent + counts include memories under + // projects shared with the user's groups. + const accessibleIds = await readableProjectIds(userId, groupNames); + const visibility = + accessibleIds.length > 0 + ? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds)) + : eq(memories.userId, userId); + + // Dashboard's "Projects" card stays owned-only — the list of projects + // you actively own. Shared projects show up via the memory list and + // the per-project page; surfacing them here would make the panel + // confusing about who owns what. const [counts, recent, topProjects] = await Promise.all([ db .select({ total: count(memories.id), }) .from(memories) - .where(and(eq(memories.userId, userId), isNull(memories.deletedAt))), + .where(and(visibility!, isNull(memories.deletedAt))), db .select({ id: memories.id, @@ -29,11 +43,12 @@ export default async function DashboardPage() { scope: memories.scope, tags: memories.tags, createdAt: memories.createdAt, + projectId: memories.projectId, projectKey: projects.key, }) .from(memories) .leftJoin(projects, eq(memories.projectId, projects.id)) - .where(and(eq(memories.userId, userId), isNull(memories.deletedAt))) + .where(and(visibility!, isNull(memories.deletedAt))) .orderBy(desc(memories.createdAt)) .limit(5), db @@ -54,6 +69,22 @@ export default async function DashboardPage() { .limit(4), ]); + // Annotate "Shared" chips on the recent panel. + const projectIds = recent + .map((r) => r.projectId) + .filter((p): p is string => p !== null); + const sharedProjects = + projectIds.length > 0 + ? new Set( + ( + await db + .selectDistinct({ projectId: projectShares.projectId }) + .from(projectShares) + .where(inArray(projectShares.projectId, projectIds)) + ).map((r) => r.projectId), + ) + : new Set(); + const memoryTotal = counts[0]?.total ?? 0; return ( @@ -94,6 +125,11 @@ export default async function DashboardPage() { {m.scope} + {m.projectId && sharedProjects.has(m.projectId) ? ( + + Shared + + ) : null} {m.projectKey ? · {m.projectKey} : null} {new Date(m.createdAt).toLocaleDateString()} diff --git a/apps/web/app/(authed)/memories/[id]/page.tsx b/apps/web/app/(authed)/memories/[id]/page.tsx index b4e1695..272ccc9 100644 --- a/apps/web/app/(authed)/memories/[id]/page.tsx +++ b/apps/web/app/(authed)/memories/[id]/page.tsx @@ -1,10 +1,11 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, or } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/lib/db/client"; -import { memories, projects } from "@/lib/db/schema"; +import { memories, projects, projectShares, groups, users } from "@/lib/db/schema"; import { updateMemoryAction, deleteMemoryAction } from "@/lib/memory-actions"; +import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access"; import { Container, PageHeader } from "@/app/_components/ui/container"; import { Card, CardBody, CardHeader } from "@/app/_components/ui/card"; import { Input, Textarea, Label } from "@/app/_components/ui/input"; @@ -22,31 +23,81 @@ export default async function MemoryDetailPage({ }) { const session = await auth(); const userId = session!.user.id; + const groupNames = await getUserGroupNames(userId); const { id } = await params; const { edit } = await searchParams; + // Widen the visibility predicate: a user can see a memory they own, + // or any memory whose project is shared with them. Project_id filter + // uses the precomputed accessible-id list for parity with the search + // / list paths. + const accessibleProjectIds = await readableProjectIds(userId, groupNames); + const visibility = + accessibleProjectIds.length > 0 + ? or( + eq(memories.userId, userId), + inArray(memories.projectId, accessibleProjectIds), + ) + : eq(memories.userId, userId); + const rows = await db .select({ id: memories.id, scope: memories.scope, content: memories.content, tags: memories.tags, + version: memories.version, + lastEditedBy: memories.lastEditedBy, createdAt: memories.createdAt, updatedAt: memories.updatedAt, projectKey: projects.key, projectId: memories.projectId, + ownerUserId: memories.userId, }) .from(memories) .leftJoin(projects, eq(memories.projectId, projects.id)) - .where( - and(eq(memories.id, id), eq(memories.userId, userId), isNull(memories.deletedAt)), - ) + .where(and(eq(memories.id, id), isNull(memories.deletedAt), visibility!)) .limit(1); const m = rows[0]; if (!m) notFound(); - const isEditing = edit === "1"; + // Determine the viewer's write permission. user-scope memories = + // owner-only; project-scope = canWriteProject. Used to gate the + // Edit / Delete affordances. + let canWrite: boolean; + if (m.scope === "user") { + canWrite = m.ownerUserId === userId; + } else if (m.projectId) { + const access = await getProjectAccess(userId, groupNames, m.projectId); + canWrite = access === "owner" || access === "rw"; + } else { + canWrite = false; + } + + const isEditing = edit === "1" && canWrite; + + // Shares on this project drive the "Shared" chip plus an editor-name + // lookup (we want to display who last edited, even if they're another + // member of the same group). + const shareRows = m.projectId + ? await db + .select({ groupName: groups.name }) + .from(projectShares) + .innerJoin(groups, eq(groups.id, projectShares.groupId)) + .where(eq(projectShares.projectId, m.projectId)) + : []; + + const editorRow = m.lastEditedBy + ? await db + .select({ name: users.name, email: users.email }) + .from(users) + .where(eq(users.id, m.lastEditedBy)) + .limit(1) + : []; + const editorLabel = editorRow[0] + ? editorRow[0].name ?? editorRow[0].email ?? "unknown" + : null; const projectList = isEditing ? await db @@ -67,7 +118,7 @@ export default async function MemoryDetailPage({ - {!isEditing ? ( + {!isEditing && canWrite ? ( @@ -77,19 +128,33 @@ export default async function MemoryDetailPage({ /> - + {m.scope} {m.projectKey ? {m.projectKey} : null} + {shareRows.length > 0 ? ( + s.groupName).join(", ")}`} + > + Shared + + ) : null} · Created {new Date(m.createdAt).toLocaleString()} {m.updatedAt.getTime() !== m.createdAt.getTime() ? ( · Updated {new Date(m.updatedAt).toLocaleString()} ) : null} + {editorLabel && m.lastEditedBy !== m.ownerUserId ? ( + + · Last edited by {editorLabel} + + ) : null} {isEditing ? (
+
- - - + {canWrite ? ( + + + + ) : null} } /> + {shareRows.length > 0 || isOwner ? ( + + + Sharing + + {shareRows.length === 0 + ? "No groups have access" + : `${shareRows.length} group${shareRows.length === 1 ? "" : "s"}`} + + + + {shareRows.length === 0 && !isOwner ? ( +

Only the owner has access.

+ ) : null} + + {shareRows.length > 0 ? ( +
    + {shareRows.map((s) => ( +
  • + {s.groupName} + + {s.access} + + + since {new Date(s.grantedAt).toLocaleDateString()} + + {isOwner ? ( +
    + + + + + + +
    + + + +
    +
    + ) : null} +
  • + ))} +
+ ) : null} + + {isOwner ? ( +
+ +
+ + + {myGroups.length > 0 ? ( + + {myGroups.map((g) => ( + + ))} + + ) : null} +
+
+ +
+ + +
+
+ +
+ ) : null} +
+
+ ) : null} + {mem.length === 0 ? ( - - + canWrite ? ( + + + + ) : null } /> ) : ( @@ -99,6 +339,14 @@ export default async function ProjectDetailPage({ {m.scope} + {shareRows.length > 0 ? ( + s.groupName).join(", ")}`} + > + Shared + + ) : null} {new Date(m.createdAt).toLocaleString()}

{m.content}

diff --git a/apps/web/app/(authed)/snippets/[name]/page.tsx b/apps/web/app/(authed)/snippets/[name]/page.tsx index 71f9600..988170d 100644 --- a/apps/web/app/(authed)/snippets/[name]/page.tsx +++ b/apps/web/app/(authed)/snippets/[name]/page.tsx @@ -1,11 +1,12 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/lib/db/client"; -import { snippets, projects } from "@/lib/db/schema"; +import { snippets, projects, users } from "@/lib/db/schema"; import { updateSnippetAction, deleteSnippetAction } from "@/lib/snippet-actions"; import { getSnippet, type SnippetWithProjectKey } from "@/lib/snippets"; +import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access"; import { Container, PageHeader } from "@/app/_components/ui/container"; import { Card, CardBody, CardHeader } from "@/app/_components/ui/card"; import { Input, Textarea, Label } from "@/app/_components/ui/input"; @@ -23,8 +24,25 @@ interface SiblingHit { * When a snippet name exists in more than one scope (e.g. a user-scope * default plus one or more project-scope variants), we need to either * disambiguate by query string or, if no hint is given, show a picker. + * + * Visibility widening: with sharing, the user may also see project- + * scope snippets under shared projects. Match rows that the viewer can + * read (own user-scope rows, or project-scope rows in an accessible + * project). */ -async function findAllMatches(userId: string, name: string): Promise { +async function findAllMatches( + userId: string, + groupNames: string[], + name: string, +): Promise { + const accessibleProjectIds = await readableProjectIds(userId, groupNames); + const visibility = + accessibleProjectIds.length > 0 + ? or( + and(eq(snippets.userId, userId), isNull(snippets.projectId)), + inArray(snippets.projectId, accessibleProjectIds), + ) + : and(eq(snippets.userId, userId), isNull(snippets.projectId)); const rows = await db .select({ scope: snippets.scope, @@ -32,7 +50,7 @@ async function findAllMatches(userId: string, name: string): Promise - {!isEditing ? ( + {!isEditing && canWrite ? ( · Updated {new Date(snippet.updatedAt).toLocaleString()}
) : null} + {editorLabel && snippet.lastEditedBy !== snippet.userId ? ( + · Last edited by {editorLabel} + ) : null} {isEditing ? ( @@ -157,6 +205,7 @@ export default async function SnippetDetailPage({
+ {snippet.scope === "project" && snippet.projectKey ? ( ) : null} @@ -234,7 +283,7 @@ export default async function SnippetDetailPage({ )} - {!isEditing ? ( + {!isEditing && canWrite ? ( diff --git a/apps/web/drizzle/0004_project_shares.sql b/apps/web/drizzle/0004_project_shares.sql new file mode 100644 index 0000000..6dbd376 --- /dev/null +++ b/apps/web/drizzle/0004_project_shares.sql @@ -0,0 +1,64 @@ +-- Phase 4c+d+e: project sharing + optimistic-locking version columns. +-- +-- Depends on Agent A's `0003_groups.sql`, which introduces: +-- - `groups` table (id, oidc_iss, name, …) +-- - `user_groups` membership table +-- - `memory_access` enum ('ro', 'rw') +-- +-- This migration is the sharing layer on top of those foundations plus +-- the co-edit primitives that make multi-user editing safe. + +-- ============================================================================= +-- project_shares: grants a group access to a project +-- ============================================================================= +-- +-- One row per (project, group) pair. Access level controls whether +-- members of the group can mutate rows under that project (rw) or only +-- observe them (ro). Owners (projects.user_id = users.id) always retain +-- full control regardless of any project_shares rows. +-- +-- granted_by is informational — `SET NULL` on user delete so the share +-- itself outlives the granter's account. + +CREATE TABLE "project_shares" ( + "project_id" uuid NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "group_id" uuid NOT NULL REFERENCES "groups"("id") ON DELETE CASCADE, + "access" memory_access NOT NULL, + "granted_at" timestamptz NOT NULL DEFAULT now(), + "granted_by" uuid REFERENCES "users"("id") ON DELETE SET NULL, + PRIMARY KEY ("project_id", "group_id") +); + +-- Lookups go in both directions: "what's shared with group G" (used when +-- resolving a user's accessible projects via their group memberships) and +-- "who has access to project P" (used on the project detail page). +-- The primary key already covers the second; this index covers the first. +CREATE INDEX "project_shares_group_idx" ON "project_shares" ("group_id"); + +-- ============================================================================= +-- memories.version + memories.last_edited_by +-- ============================================================================= +-- +-- `version` starts at 1 on insert and is bumped by every UPDATE. Edit +-- forms and MCP `memory.update` pass the version they observed; the +-- UPDATE's WHERE clause includes `AND version = $version`, so a stale +-- caller gets 0 rows updated and we surface a "refresh and try again" +-- error rather than clobber a concurrent edit. +-- +-- `last_edited_by` records who performed the most recent UPDATE. + +ALTER TABLE "memories" + ADD COLUMN "version" integer NOT NULL DEFAULT 1, + ADD COLUMN "last_edited_by" uuid REFERENCES "users"("id") ON DELETE SET NULL; + +-- ============================================================================= +-- snippets.version + snippets.last_edited_by +-- ============================================================================= +-- +-- Same shape and rationale as memories. Co-editable snippets live in +-- shared projects; user-scope snippets remain single-author in practice +-- but the columns are uniform across both scopes for simplicity. + +ALTER TABLE "snippets" + ADD COLUMN "version" integer NOT NULL DEFAULT 1, + ADD COLUMN "last_edited_by" uuid REFERENCES "users"("id") ON DELETE SET NULL; diff --git a/apps/web/lib/access.ts b/apps/web/lib/access.ts new file mode 100644 index 0000000..24136db --- /dev/null +++ b/apps/web/lib/access.ts @@ -0,0 +1,210 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import { + groups, + projects, + projectShares, + userGroups, +} from "@/lib/db/schema"; + +/** + * Authorization helpers for the project-sharing model. + * + * Access semantics: + * - Owner (projects.user_id = U.id): full read + write. + * - Group share (project_shares.group_id in U.groups): + * access='ro' → read only + * access='rw' → read + write + * + * Lookups in this module are intentionally cheap and small — they only + * resolve project_ids the user can touch. Per-row queries embed those + * ids in their WHERE clauses (or use IN subqueries) so the database still + * does the heavy lifting; we never load all-of-project-X into memory to + * filter in JS. + * + * Why a separate module: callers come from three places + * (`memory-actions`, `snippet-actions`, `mcp/tools`, plus the `lib/` + * search/list helpers), and replicating the same SQL three ways was + * the previous source of inconsistency this phase fixes. + */ + +export type ProjectAccess = "owner" | "ro" | "rw"; + +export interface AccessibleProject { + projectId: string; + access: ProjectAccess; + projectKey: string; +} + +/** + * Resolve the set of project ids `userId` can read, with the strongest + * access level for each. Owner > rw > ro. Used by listing/search paths + * that need to widen their WHERE clauses to include shared projects. + * + * Group names are matched case-sensitively against the `groups` table — + * the OIDC claim names are the contract. An empty `groupNames` is fine; + * the user just won't see any shared projects. + */ +export async function getAccessibleProjects( + userId: string, + groupNames: string[], +): Promise { + const owned = await db + .select({ projectId: projects.id, projectKey: projects.key }) + .from(projects) + .where(eq(projects.userId, userId)); + + const ownedMap = new Map( + owned.map((r) => ({ + projectId: r.projectId, + access: "owner" as const, + projectKey: r.projectKey, + })).map((r) => [r.projectId, r] as const), + ); + + if (groupNames.length === 0) { + return [...ownedMap.values()]; + } + + // Join project_shares → groups → projects so we get the project key + // alongside the access level in a single query. + const shared = await db + .select({ + projectId: projectShares.projectId, + access: projectShares.access, + projectKey: projects.key, + }) + .from(projectShares) + .innerJoin(groups, eq(groups.id, projectShares.groupId)) + .innerJoin(projects, eq(projects.id, projectShares.projectId)) + .where(inArray(groups.name, groupNames)); + + for (const r of shared) { + const existing = ownedMap.get(r.projectId); + if (existing) continue; // owner already wins + // If two of the user's groups both share the same project at + // different levels, keep the stronger one (rw beats ro). + const prior = ownedMap.get(r.projectId); + if (prior && prior.access === "rw") continue; + ownedMap.set(r.projectId, { + projectId: r.projectId, + access: r.access as "ro" | "rw", + projectKey: r.projectKey, + }); + } + + return [...ownedMap.values()]; +} + +/** + * Resolve project access for a single project_id. Returns null when the + * user has no access at all (deny by default). Owner check is short- + * circuited: we don't query project_shares unless the user isn't owner. + */ +export async function getProjectAccess( + userId: string, + groupNames: string[], + projectId: string, +): Promise { + const owned = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.userId, userId))) + .limit(1); + if (owned[0]) return "owner"; + + if (groupNames.length === 0) return null; + + const sharedRows = await db + .select({ access: projectShares.access }) + .from(projectShares) + .innerJoin(groups, eq(groups.id, projectShares.groupId)) + .where( + and( + eq(projectShares.projectId, projectId), + inArray(groups.name, groupNames), + ), + ); + + if (sharedRows.length === 0) return null; + // If a user is in multiple groups with different levels on the same + // project, pick the strongest. + return sharedRows.some((r) => r.access === "rw") ? "rw" : "ro"; +} + +/** + * "Can this user read project P?" — true for owner, ro, or rw. + */ +export async function canReadProject( + userId: string, + groupNames: string[], + projectId: string, +): Promise { + const access = await getProjectAccess(userId, groupNames, projectId); + return access !== null; +} + +/** + * "Can this user write to project P?" — true for owner or rw share. + */ +export async function canWriteProject( + userId: string, + groupNames: string[], + projectId: string, +): Promise { + const access = await getProjectAccess(userId, groupNames, projectId); + return access === "owner" || access === "rw"; +} + +/** + * Project ids that this user has READ access to (own + any shared). Used + * by candidate-fetch WHERE clauses on listings and search. The empty + * set is encoded explicitly: callers should treat it as "no rows". + */ +export async function readableProjectIds( + userId: string, + groupNames: string[], +): Promise { + const all = await getAccessibleProjects(userId, groupNames); + return all.map((p) => p.projectId); +} + +/** + * Project ids that this user has WRITE access to (own + rw shares). + */ +export async function writableProjectIds( + userId: string, + groupNames: string[], +): Promise { + const all = await getAccessibleProjects(userId, groupNames); + return all.filter((p) => p.access !== "ro").map((p) => p.projectId); +} + +/** + * The error message returned to any caller that lost an optimistic- + * locking race. Centralized so the wording stays consistent across MCP + * tools and Server Actions; callers also key off the prefix to surface + * a "Refresh" UI affordance if they care. + */ +export const CONCURRENT_EDIT_ERROR = + "Memory was modified by someone else since you loaded it. Refresh and try again."; + +export const CONCURRENT_EDIT_ERROR_SNIPPET = + "Snippet was modified by someone else since you loaded it. Refresh and try again."; + +/** + * Fetch the group names this user is currently a member of from the + * `user_groups` table. Used by Web UI Server Actions and pages — the + * web session's JWT may carry the same list, but reading from the DB + * means we don't have to coordinate with Agent A's session-callback + * change to consume sharing semantics here. Agent A's sign-in callback + * keeps `user_groups` in sync with the OIDC `groups` claim. + */ +export async function getUserGroupNames(userId: string): Promise { + const rows = await db + .select({ name: groups.name }) + .from(userGroups) + .innerJoin(groups, eq(groups.id, userGroups.groupId)) + .where(eq(userGroups.userId, userId)); + return rows.map((r) => r.name); +} diff --git a/apps/web/lib/db/schema.ts b/apps/web/lib/db/schema.ts index 669a98c..f6f84f1 100644 --- a/apps/web/lib/db/schema.ts +++ b/apps/web/lib/db/schema.ts @@ -10,6 +10,8 @@ import { customType, vector, varchar, + integer, + primaryKey, } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; @@ -37,6 +39,10 @@ const textArray = customType<{ data: string[]; driverData: string }>({ export const memoryScope = pgEnum("memory_scope", ["project", "user"]); export const memoryVisibility = pgEnum("memory_visibility", ["private", "shared", "team"]); export const auditActor = pgEnum("audit_actor", ["mcp", "web", "system"]); +// `memory_access` is created by Agent A's `0003_groups.sql`. Declared here +// so Drizzle's TS layer can reference the enum from `project_shares`. The +// enum values must stay in lock-step with that migration. +export const memoryAccess = pgEnum("memory_access", ["ro", "rw"]); // ---------- tables ---------- @@ -94,6 +100,14 @@ export const memories = pgTable( embedding: vector("embedding", { dimensions: 384 }), // Generated column — see migration SQL for definition. contentTsv: tsvector("content_tsv"), + // Optimistic-locking counter. Bumped on every successful UPDATE so + // concurrent edits (now possible across shared-project members) can + // detect lost-write situations and surface "refresh and try again". + version: integer("version").notNull().default(1), + // The user whose UPDATE most recently mutated this row. NULL only on + // the very first INSERT (pre-update). FK is `SET NULL` so deleting + // an account doesn't wipe other people's memories. + lastEditedBy: uuid("last_edited_by").references(() => users.id, { onDelete: "set null" }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), deletedAt: timestamp("deleted_at", { withTimezone: true }), @@ -121,6 +135,11 @@ export const snippets = pgTable( body: text("body").notNull(), description: text("description"), tags: textArray("tags").notNull().default([]), + // See `memories.version` / `memories.lastEditedBy` for the rationale — + // snippets in shared projects can now be co-edited so we need the same + // optimistic-locking primitive here. + version: integer("version").notNull().default(1), + lastEditedBy: uuid("last_edited_by").references(() => users.id, { onDelete: "set null" }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), deletedAt: timestamp("deleted_at", { withTimezone: true }), @@ -152,6 +171,80 @@ export const cliTokens = pgTable( }), ); +// ---------- groups + sharing ---------- +// +// `groups` and `user_groups` are owned by Agent A's `0003_groups.sql` +// migration. We declare the Drizzle table objects here so this phase's +// code (project sharing, authorization helpers, the share-management UI) +// can reference them through the same `@/lib/db/schema` import path the +// rest of the codebase uses. The column shape MUST stay aligned with +// Agent A's migration; if their values change, update both sides. +export const groups = pgTable( + "groups", + { + id: uuid("id").primaryKey().defaultRandom(), + // OIDC issuer that minted the group claim. Lets us federate later + // without name collisions between two IdPs that both have e.g. + // "engineering". + oidcIss: text("oidc_iss").notNull(), + // Group `name` as it appears in the JWT (Authentik groups claim). + name: text("name").notNull(), + displayName: text("display_name"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + uniqueIssName: uniqueIndex("groups_iss_name_uq").on(t.oidcIss, t.name), + }), +); + +export const userGroups = pgTable( + "user_groups", + { + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + groupId: uuid("group_id") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + // When the row was most recently confirmed by an OIDC sign-in. Agent + // A bumps this on every successful auth so a stale membership can be + // detected. + syncedAt: timestamp("synced_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.userId, t.groupId] }), + groupIdx: index("user_groups_group_idx").on(t.groupId), + }), +); + +// `project_shares` grants a `group` access to a `project`. Each row +// authorizes every user in that group to read (and, when access='rw', +// write) every memory + snippet under that project. The (project_id, +// group_id) composite PK enforces "one share per (project, group)". +// +// Owners share projects from the Web UI; the MCP layer can list shared +// projects via project.identify but cannot grant new shares. +export const projectShares = pgTable( + "project_shares", + { + projectId: uuid("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + groupId: uuid("group_id") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + access: memoryAccess("access").notNull(), + grantedAt: timestamp("granted_at", { withTimezone: true }).notNull().defaultNow(), + // Audit-friendly. `SET NULL` so deleting the granter's account doesn't + // cascade-remove the share. + grantedBy: uuid("granted_by").references(() => users.id, { onDelete: "set null" }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.projectId, t.groupId] }), + groupIdx: index("project_shares_group_idx").on(t.groupId), + }), +); + export const auditLog = pgTable( "audit_log", { @@ -188,3 +281,9 @@ export type CliToken = typeof cliTokens.$inferSelect; export type NewCliToken = typeof cliTokens.$inferInsert; export type AuditEntry = typeof auditLog.$inferSelect; export type NewAuditEntry = typeof auditLog.$inferInsert; +export type Group = typeof groups.$inferSelect; +export type NewGroup = typeof groups.$inferInsert; +export type UserGroup = typeof userGroups.$inferSelect; +export type NewUserGroup = typeof userGroups.$inferInsert; +export type ProjectShare = typeof projectShares.$inferSelect; +export type NewProjectShare = typeof projectShares.$inferInsert; diff --git a/apps/web/lib/mcp/context.ts b/apps/web/lib/mcp/context.ts index 2821092..d0b88fe 100644 --- a/apps/web/lib/mcp/context.ts +++ b/apps/web/lib/mcp/context.ts @@ -9,6 +9,12 @@ import type { AuthenticatedClaims } from "@/lib/auth/jwt"; * Resolves (or creates) the internal `users` row from the Authentik OIDC * claims so tools work with stable UUID foreign keys rather than raw `sub` * strings. + * + * `groups` and `defaultProjectKey` are populated by the MCP route handler + * (Agent A's work): groups come from the JWT's `groups` claim cross- + * checked against the `user_groups` table; defaultProjectKey is the value + * of the `X-Project-Key` header attached to the inbound request, used as + * a fallback when a tool call doesn't include `project` explicitly. */ export interface UserContext { /** Internal users.id UUID. */ @@ -20,6 +26,19 @@ export interface UserContext { /** Optional profile fields if present in the access token. */ email: string | null; name: string | null; + /** + * Group names this user belongs to (from the OIDC `groups` claim, + * cross-checked against `user_groups`). Drives sharing authorization; + * empty means the user is only a member of their own private projects. + */ + groups: string[]; + /** + * Default project key from the inbound MCP request's `X-Project-Key` + * header. Tools that accept a `project` argument fall back to this + * when none is provided so a Claude Code session pre-pinned to a repo + * doesn't have to repeat it on every call. + */ + defaultProjectKey?: string; } export async function userContextFromClaims(claims: AuthenticatedClaims): Promise { @@ -56,8 +75,17 @@ export async function userContextFromClaims(claims: AuthenticatedClaims): Promis .where(and(eq(users.oidcIss, claims.iss), eq(users.oidcSub, claims.sub))) .limit(1); if (!existing[0]) throw new Error("user upsert failed and not found on re-read"); - return { userId: existing[0].id, sub: claims.sub, iss: claims.iss, email, name }; + return { + userId: existing[0].id, + sub: claims.sub, + iss: claims.iss, + email, + name, + groups: [], + }; } - return { userId, sub: claims.sub, iss: claims.iss, email, name }; + // `groups` is left empty here; Agent A's MCP route handler widens it + // with the JWT's `groups` claim after this base context resolves. + return { userId, sub: claims.sub, iss: claims.iss, email, name, groups: [] }; } diff --git a/apps/web/lib/mcp/tools.ts b/apps/web/lib/mcp/tools.ts index 927b3d2..e8b0c3c 100644 --- a/apps/web/lib/mcp/tools.ts +++ b/apps/web/lib/mcp/tools.ts @@ -1,6 +1,12 @@ -import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { db } from "@/lib/db/client"; -import { memories, projects, auditLog } from "@/lib/db/schema"; +import { + memories, + projects, + projectShares, + groups, + auditLog, +} from "@/lib/db/schema"; import { MemoryIdInput, MemoryListInput, @@ -21,6 +27,12 @@ import { listSnippets, softDeleteSnippet, } from "@/lib/snippets"; +import { + CONCURRENT_EDIT_ERROR, + canWriteProject, + getProjectAccess, + readableProjectIds, +} from "@/lib/access"; import type { UserContext } from "./context"; /** @@ -60,17 +72,46 @@ function err(message: string): ToolResult { }; } +/** + * Resolve a project_id for a project key visible to this user. Prefers + * an owned project, falls back to any project shared with one of the + * user's groups (any access level — read is enough to resolve the id). + * Returns null when no visible project matches. + */ async function resolveProjectId( ctx: UserContext, projectKey: string | undefined, ): Promise { if (!projectKey) return null; - const row = await db + const owned = await db .select({ id: projects.id }) .from(projects) .where(and(eq(projects.userId, ctx.userId), eq(projects.key, projectKey))) .limit(1); - return row[0]?.id ?? null; + if (owned[0]) return owned[0].id; + + if (ctx.groups.length === 0) return null; + + const accessibleIds = await readableProjectIds(ctx.userId, ctx.groups); + if (accessibleIds.length === 0) return null; + const shared = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.key, projectKey), inArray(projects.id, accessibleIds))) + .limit(1); + return shared[0]?.id ?? null; +} + +/** + * Resolve the project key from a tool call: explicit `project` arg takes + * precedence; otherwise fall back to the context-level default (the + * `X-Project-Key` header parsed by the MCP route). + */ +function projectKeyOrDefault( + ctx: UserContext, + arg: string | undefined, +): string | undefined { + return arg ?? ctx.defaultProjectKey; } // ---------- tools ---------- @@ -78,7 +119,7 @@ async function resolveProjectId( const projectIdentify: ToolDef = { name: "project.identify", description: - "Call ONCE near the start of every session that has a project context — a repo you're working in, a service you're debugging, etc. — to register or look up that project so subsequent project-scoped memories attach correctly. Use a stable `key` you can reproduce next session (repo name, repo URL, or working directory basename). Skip if the work is purely scratch / not tied to a specific codebase.", + "Call ONCE near the start of every session that has a project context — a repo you're working in, a service you're debugging, etc. — to register or look up that project so subsequent project-scoped memories attach correctly. Use a stable `key` you can reproduce next session (repo name, repo URL, or working directory basename). Returns shared projects you have access to in addition to your own; when an owned and a shared project would both match the same key, the owned one wins (a server-side warning is logged so the collision is debuggable). Skip if the work is purely scratch / not tied to a specific codebase.", inputSchema: { type: "object", properties: { @@ -98,20 +139,121 @@ const projectIdentify: ToolDef = { const parsed = ProjectIdentifyInput.safeParse(args); if (!parsed.success) return err(parsed.error.message); - const row = await db + // 1) Owned project with this key wins. + const ownedRow = await db + .select({ + id: projects.id, + key: projects.key, + displayName: projects.displayName, + createdAt: projects.createdAt, + userId: projects.userId, + }) + .from(projects) + .where(and(eq(projects.userId, ctx.userId), eq(projects.key, parsed.data.key))) + .limit(1); + + // 2) If the user belongs to any groups, find shared projects with + // this key. We collect ALL matches because we need to (a) detect + // a collision with the owned match to emit the warning, and + // (b) collapse the access level across the user's groups. + let sharedMatches: Array<{ + projectId: string; + displayName: string | null; + createdAt: Date; + ownerUserId: string; + access: "ro" | "rw"; + }> = []; + if (ctx.groups.length > 0) { + const rows = await db + .select({ + projectId: projects.id, + displayName: projects.displayName, + createdAt: projects.createdAt, + ownerUserId: projects.userId, + access: projectShares.access, + }) + .from(projectShares) + .innerJoin(groups, eq(groups.id, projectShares.groupId)) + .innerJoin(projects, eq(projects.id, projectShares.projectId)) + .where( + and( + inArray(groups.name, ctx.groups), + eq(projects.key, parsed.data.key), + ), + ); + sharedMatches = rows as typeof sharedMatches; + } + + if (ownedRow[0]) { + // Owned beats shared — but if there's a shared collision, audit + // the warning so an operator can see the ambiguity in the log + // surface. We don't surface it on the caller's response. + const collidesWithShared = sharedMatches.some( + (s) => s.projectId !== ownedRow[0]!.id, + ); + if (collidesWithShared) { + await db.insert(auditLog).values({ + userId: ctx.userId, + actor: "system", + action: "project.identify.collision", + entityType: "project", + entityId: ownedRow[0].id, + payload: { + projectKey: parsed.data.key, + ownedProjectId: ownedRow[0].id, + sharedProjectIds: sharedMatches.map((s) => s.projectId), + note: "owned project preferred over shared collision", + }, + }); + } + // Apply display_name update only on the owned project. + if (parsed.data.display_name) { + await db + .update(projects) + .set({ displayName: parsed.data.display_name, updatedAt: new Date() }) + .where(eq(projects.id, ownedRow[0].id)); + } + return ok( + { + id: ownedRow[0].id, + key: ownedRow[0].key, + displayName: parsed.data.display_name ?? ownedRow[0].displayName, + createdAt: ownedRow[0].createdAt, + shared: false, + access: "owner" as const, + readOnly: false, + }, + `project ${ownedRow[0].key} (${ownedRow[0].id})`, + ); + } + + if (sharedMatches.length > 0) { + // Collapse to the strongest access level across the user's groups. + const access = sharedMatches.some((s) => s.access === "rw") ? "rw" : "ro"; + // De-dupe — multiple group rows can point at the same project. + const first = sharedMatches[0]!; + return ok( + { + id: first.projectId, + key: parsed.data.key, + displayName: first.displayName, + createdAt: first.createdAt, + shared: true, + access, + readOnly: access === "ro", + }, + `project ${parsed.data.key} (shared, ${access})`, + ); + } + + // 3) Nothing matched — create a new owned project. + const created = await db .insert(projects) .values({ userId: ctx.userId, key: parsed.data.key, displayName: parsed.data.display_name ?? null, }) - .onConflictDoUpdate({ - target: [projects.userId, projects.key], - set: { - displayName: parsed.data.display_name ?? sql`${projects.displayName}`, - updatedAt: new Date(), - }, - }) .returning({ id: projects.id, key: projects.key, @@ -119,22 +261,34 @@ const projectIdentify: ToolDef = { createdAt: projects.createdAt, }); - const p = row[0]!; - return ok(p, `project ${p.key} (${p.id})`); + const p = created[0]!; + return ok( + { + id: p.id, + key: p.key, + displayName: p.displayName, + createdAt: p.createdAt, + shared: false, + access: "owner" as const, + readOnly: false, + }, + `project ${p.key} (${p.id})`, + ); }, }; const memoryWrite: ToolDef = { name: "memory.write", description: - "Save a durable fact, preference, or decision that ANY future Claude Code session on ANY of this user's machines should know. Call this when the user shares something that meets ALL of: (1) likely to matter beyond this conversation, (2) not derivable from reading current code/git, (3) would surprise a future you if forgotten. Examples: 'I use HAProxy at home' (user-scope), 'we chose Drizzle over Prisma because of bundle size' (project-scope), 'our prod DB is at db.example.com' (user-scope reference). Use scope='user' for facts about the human or their infra; scope='project' for facts tied to a specific codebase (always preceded by project.identify). Sensitive info (API keys, credentials, connection strings the user actively shares with you) IS appropriate to save here — this server is OIDC-gated and per-user; safer than writing to local container files. DO NOT use for: transient task state, this-session-only scratch notes, or container-specific facts (those belong in the built-in file-based memory at ~/.claude/.../memory/). Tags help retrieval.", + "Save a durable fact, preference, or decision that ANY future Claude Code session on ANY of this user's machines should know. Call this when the user shares something that meets ALL of: (1) likely to matter beyond this conversation, (2) not derivable from reading current code/git, (3) would surprise a future you if forgotten. Examples: 'I use HAProxy at home' (user-scope), 'we chose Drizzle over Prisma because of bundle size' (project-scope), 'our prod DB is at db.example.com' (user-scope reference). Use scope='user' for facts about the human or their infra; scope='project' for facts tied to a specific codebase (always preceded by project.identify). In shared projects (i.e. ones surfaced by project.identify with `shared: true`), anyone with rw access can write — your memory becomes visible to every member of every group the project is shared with. Defaults `project` to the X-Project-Key header value if not supplied. Sensitive info (API keys, credentials, connection strings the user actively shares with you) IS appropriate to save here — this server is OIDC-gated and per-user; safer than writing to local container files. DO NOT use for: transient task state, this-session-only scratch notes, or container-specific facts (those belong in the built-in file-based memory at ~/.claude/.../memory/). Tags help retrieval.", inputSchema: { type: "object", properties: { content: { type: "string", description: "Memory content (1–64,000 chars)." }, project: { type: "string", - description: "Project key (required when scope='project').", + description: + "Project key. Required when scope='project'; defaults to the X-Project-Key request header if present.", }, scope: { type: "string", @@ -155,11 +309,20 @@ const memoryWrite: ToolDef = { const scope = parsed.data.scope; let projectId: string | null = null; + let projectKey: string | undefined = undefined; if (scope === "project") { - if (!parsed.data.project) return err("scope=project requires `project` key"); - projectId = await resolveProjectId(ctx, parsed.data.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 '${parsed.data.project}'; call project.identify first`); + 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}'`); } } @@ -178,6 +341,7 @@ const memoryWrite: ToolDef = { content: parsed.data.content, tags: parsed.data.tags ?? [], embedding, + lastEditedBy: ctx.userId, }) .returning({ id: memories.id, createdAt: memories.createdAt }); @@ -188,7 +352,7 @@ const memoryWrite: ToolDef = { action: "memory.write", entityType: "memory", entityId: m.id, - payload: { scope, projectKey: parsed.data.project ?? null, tags: parsed.data.tags ?? [] }, + payload: { scope, projectKey: projectKey ?? null, tags: parsed.data.tags ?? [] }, }); return ok({ id: m.id, createdAt: m.createdAt }, `wrote memory ${m.id}`); @@ -216,12 +380,20 @@ const memoryList: ToolDef = { const parsed = MemoryListInput.safeParse(args); if (!parsed.success) return err(parsed.error.message); - const where = [eq(memories.userId, ctx.userId), isNull(memories.deletedAt)]; + // Visibility: own rows OR rows in any project shared with my groups. + const accessibleIds = await readableProjectIds(ctx.userId, ctx.groups); + const visibilityClause = + accessibleIds.length > 0 + ? or(eq(memories.userId, ctx.userId), inArray(memories.projectId, accessibleIds)) + : eq(memories.userId, ctx.userId); + + const where = [visibilityClause!, isNull(memories.deletedAt)]; if (parsed.data.scope) where.push(eq(memories.scope, parsed.data.scope)); - if (parsed.data.project) { - const projectId = await resolveProjectId(ctx, parsed.data.project); + const requestedKey = projectKeyOrDefault(ctx, parsed.data.project); + if (requestedKey) { + const projectId = await resolveProjectId(ctx, requestedKey); if (!projectId) return ok({ items: [], next_cursor: null }, "0 results"); where.push(eq(memories.projectId, projectId)); } @@ -237,6 +409,8 @@ const memoryList: ToolDef = { projectId: memories.projectId, content: memories.content, tags: memories.tags, + version: memories.version, + lastEditedBy: memories.lastEditedBy, createdAt: memories.createdAt, updatedAt: memories.updatedAt, }) @@ -265,17 +439,21 @@ const memoryGet: ToolDef = { const row = await db .select() .from(memories) - .where( - and( - eq(memories.id, parsed.data.id), - eq(memories.userId, ctx.userId), - isNull(memories.deletedAt), - ), - ) + .where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt))) .limit(1); if (!row[0]) return err("not found"); - return ok(row[0], `memory ${row[0].id}`); + + // 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) { + if (!m.projectId) return err("not found"); + const access = await getProjectAccess(ctx.userId, ctx.groups, m.projectId); + if (access === null) return err("not found"); + } + + return ok(m, `memory ${m.id}`); }, }; @@ -292,16 +470,34 @@ const memoryDelete: ToolDef = { const parsed = MemoryIdInput.safeParse(args); if (!parsed.success) return err(parsed.error.message); + // Look up the row first to authorize. We can't rely on a + // single-statement WHERE clause because 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, + }) + .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"); + } + const updated = await db .update(memories) - .set({ deletedAt: new Date() }) - .where( - and( - eq(memories.id, parsed.data.id), - eq(memories.userId, ctx.userId), - isNull(memories.deletedAt), - ), - ) + .set({ deletedAt: new Date(), lastEditedBy: ctx.userId }) + .where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt))) .returning({ id: memories.id }); if (!updated[0]) return err("not found"); @@ -321,7 +517,7 @@ const memoryDelete: ToolDef = { const memoryUpdate: ToolDef = { name: "memory.update", description: - "Edit an existing memory in place — for correcting a stored fact, expanding it with new detail, adjusting tags, or moving it to a different scope/project. Preserves the memory's id (so callers referencing it don't break) and re-embeds automatically when content changes. Pass `scope` and/or `project` to reclassify a memory between user-global and project-attached without recreating it. Use this — not delete + write — whenever you're refining what's already there.", + "Edit an existing memory in place — for correcting a stored fact, expanding it with new detail, adjusting tags, or moving it to a different scope/project. Preserves the memory's id (so callers referencing it don't break) and re-embeds automatically when content changes. Pass `scope` and/or `project` to reclassify a memory between user-global and project-attached without recreating it. In shared projects, anyone in a rw-access group can edit any memory — pass `version` (returned by memory.get / memory.list) to detect concurrent edits and avoid clobbering. The server bumps `version` on every successful update; a stale `version` returns the concurrent-edit error. Use this — not delete + write — whenever you're refining what's already there.", inputSchema: { type: "object", properties: { @@ -339,6 +535,12 @@ const memoryUpdate: ToolDef = { description: "Project key the memory should attach to (required and only valid when scope='project'). The project must already exist — call `project.identify` first if it doesn't.", }, + version: { + type: "integer", + minimum: 0, + description: + "Optimistic-locking token from memory.get / memory.list. When supplied, the update is rejected if the row was edited by someone else since you read it.", + }, }, required: ["id"], }, @@ -353,21 +555,29 @@ const memoryUpdate: ToolDef = { 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), - eq(memories.userId, ctx.userId), - isNull(memories.deletedAt), - ), - ) + .where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt))) .limit(1); const existing = existingRows[0]; if (!existing) return err("not found"); - const update: Record = { updatedAt: new Date() }; + // 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 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; @@ -390,12 +600,17 @@ const memoryUpdate: ToolDef = { newProjectKey = null; } } else { - // scope === 'project' — schema refine guarantees `project` is set + // 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; @@ -408,13 +623,27 @@ const memoryUpdate: ToolDef = { } } + const expectedVersion = parsed.data.version ?? existing.version; const updated = await db .update(memories) .set(update) - .where(eq(memories.id, parsed.data.id)) - .returning({ id: memories.id, updatedAt: memories.updatedAt }); + .where( + and( + eq(memories.id, parsed.data.id), + eq(memories.version, expectedVersion), + ), + ) + .returning({ + id: memories.id, + updatedAt: memories.updatedAt, + version: memories.version, + }); - const auditFields = Object.keys(update).filter((k) => k !== "updatedAt"); + if (!updated[0]) return err(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 = { @@ -460,17 +689,16 @@ const memorySearch: ToolDef = { if (!parsed.success) return err(parsed.error.message); const { query, scope, tags, limit } = parsed.data; - const projectId = parsed.data.project - ? await resolveProjectId(ctx, parsed.data.project) - : null; - if (parsed.data.project && !projectId) { + const requestedKey = projectKeyOrDefault(ctx, parsed.data.project); + const projectId = requestedKey ? await resolveProjectId(ctx, requestedKey) : null; + if (requestedKey && !projectId) { return ok({ items: [], debug: { vec: 0, fts: 0, tag: 0 } }, "0 results (unknown project)"); } const result = await searchMemories( ctx.userId, query, - { scope, projectKey: parsed.data.project, tags }, + { scope, projectKey: requestedKey, tags, groupNames: ctx.groups }, limit, ); @@ -486,6 +714,8 @@ const memorySearch: ToolDef = { projectId: memories.projectId, content: memories.content, tags: memories.tags, + version: memories.version, + lastEditedBy: memories.lastEditedBy, createdAt: memories.createdAt, updatedAt: memories.updatedAt, }) @@ -513,7 +743,7 @@ const memorySearch: ToolDef = { const snippetPut: ToolDef = { name: "snippet.put", description: - "Save or update a named reusable artifact — a template, format, or checklist the user wants applied consistently. Call this when the user says 'remember this as my X template', 'save this format as Y', or 'use this checklist whenever I do Z'. Different from memory.write (which is for facts you'll later search): snippets are fetched by EXACT name, not searched, so the name is the contract — pick something stable and predictable (e.g. 'pr-description-format', 'commit-msg-rules', 'code-review-checklist'). Use scope='user' (default) for personal templates that apply everywhere; scope='project' for repo-specific variants (requires `project`, same key you used for project.identify). Re-calling with the same name+scope replaces the body in place — there is no separate update tool. Tags help browsing in the Web UI; they do NOT enable search.", + "Save or update a named reusable artifact — a template, format, or checklist the user wants applied consistently. Call this when the user says 'remember this as my X template', 'save this format as Y', or 'use this checklist whenever I do Z'. Different from memory.write (which is for facts you'll later search): snippets are fetched by EXACT name, not searched, so the name is the contract — pick something stable and predictable (e.g. 'pr-description-format', 'commit-msg-rules', 'code-review-checklist'). Use scope='user' (default) for personal templates that apply everywhere; scope='project' for repo-specific variants (requires `project`, same key you used for project.identify, defaulted from the X-Project-Key header). Re-calling with the same name+scope replaces the body in place — there is no separate update tool. In shared projects, anyone in a rw-access group can edit any project-scope snippet — pass `version` (returned by snippet.get / snippet.list) to detect concurrent edits and avoid clobbering. Tags help browsing in the Web UI; they do NOT enable search.", inputSchema: { type: "object", properties: { @@ -534,17 +764,24 @@ const snippetPut: ToolDef = { type: "string", enum: ["project", "user"], description: - "'user' (default) = applies everywhere. 'project' = tied to one repo and requires `project`.", + "'user' (default) = applies everywhere. 'project' = tied to one repo and requires `project` (or X-Project-Key header).", }, project: { type: "string", - description: "Project key (required when scope='project').", + description: + "Project key. Required for scope='project'; defaults to the X-Project-Key request header if present.", }, tags: { type: "array", items: { type: "string" }, description: "Optional tags for grouping in the Web UI.", }, + version: { + type: "integer", + minimum: 0, + description: + "Optimistic-locking token from snippet.get / snippet.list. Only consulted on the update path (i.e. when a row with this name+scope+project already exists).", + }, }, required: ["name", "body"], }, @@ -552,48 +789,64 @@ const snippetPut: ToolDef = { const parsed = SnippetPutInput.safeParse(args); if (!parsed.success) return err(parsed.error.message); - if (parsed.data.scope === "project") { - const exists = await resolveProjectId(ctx, parsed.data.project!); - if (!exists) { - return err( - `unknown project '${parsed.data.project}'; call project.identify first`, - ); - } + // Apply X-Project-Key default for scope=project. + const projectKey = + parsed.data.scope === "project" + ? projectKeyOrDefault(ctx, parsed.data.project) + : undefined; + if (parsed.data.scope === "project" && !projectKey) { + return err("scope=project requires `project` (or X-Project-Key header)"); } - const { snippet, inserted } = await putSnippet(ctx.userId, { - name: parsed.data.name, - body: parsed.data.body, - description: parsed.data.description, - tags: parsed.data.tags, - scope: parsed.data.scope, - projectKey: parsed.data.project, - }); + if (parsed.data.scope === "project") { + const exists = await resolveProjectId(ctx, projectKey!); + // It's OK for the project not to exist — putSnippet will create + // it owned by the caller. But if it DOES exist as a shared + // project, we need rw to write through it; the helper enforces + // that. + void exists; + } - await db.insert(auditLog).values({ - userId: ctx.userId, - actor: "mcp", - action: inserted ? "snippet.put" : "snippet.update", - entityType: "snippet", - entityId: snippet.id, - payload: { - name: snippet.name, - scope: snippet.scope, - projectKey: snippet.projectKey, - tags: snippet.tags, - }, - }); + try { + const { snippet, inserted } = await putSnippet(ctx.userId, { + name: parsed.data.name, + body: parsed.data.body, + description: parsed.data.description, + tags: parsed.data.tags, + scope: parsed.data.scope, + projectKey, + groupNames: ctx.groups, + version: parsed.data.version, + }); - return ok( - { - id: snippet.id, - name: snippet.name, - scope: snippet.scope, - project: snippet.projectKey, - inserted, - }, - `${inserted ? "wrote" : "updated"} snippet '${snippet.name}' (${snippet.scope})`, - ); + await db.insert(auditLog).values({ + userId: ctx.userId, + actor: "mcp", + action: inserted ? "snippet.put" : "snippet.update", + entityType: "snippet", + entityId: snippet.id, + payload: { + name: snippet.name, + scope: snippet.scope, + projectKey: snippet.projectKey, + tags: snippet.tags, + }, + }); + + return ok( + { + id: snippet.id, + name: snippet.name, + scope: snippet.scope, + project: snippet.projectKey, + version: snippet.version, + inserted, + }, + `${inserted ? "wrote" : "updated"} snippet '${snippet.name}' (${snippet.scope})`, + ); + } catch (e) { + return err(e instanceof Error ? e.message : "snippet.put failed"); + } }, }; @@ -623,10 +876,12 @@ const snippetGet: ToolDef = { const parsed = SnippetGetInput.safeParse(args); if (!parsed.success) return err(parsed.error.message); + const requestedKey = projectKeyOrDefault(ctx, parsed.data.project); const snippet = await getSnippet(ctx.userId, { name: parsed.data.name, scope: parsed.data.scope, - projectKey: parsed.data.project, + projectKey: requestedKey, + groupNames: ctx.groups, }); if (!snippet) return err(`snippet '${parsed.data.name}' not found`); @@ -640,6 +895,8 @@ const snippetGet: ToolDef = { scope: snippet.scope, project: snippet.projectKey, tags: snippet.tags, + version: snippet.version, + lastEditedBy: snippet.lastEditedBy, createdAt: snippet.createdAt, updatedAt: snippet.updatedAt, }, @@ -672,11 +929,13 @@ const snippetList: ToolDef = { const parsed = SnippetListInput.safeParse(args); if (!parsed.success) return err(parsed.error.message); + const requestedKey = projectKeyOrDefault(ctx, parsed.data.project); const rows = await listSnippets(ctx.userId, { scope: parsed.data.scope, - projectKey: parsed.data.project, + projectKey: requestedKey, tags: parsed.data.tags, limit: parsed.data.limit, + groupNames: ctx.groups, }); const items = rows.map((r) => ({ @@ -686,6 +945,8 @@ const snippetList: ToolDef = { scope: r.scope, project: r.projectKey, tags: r.tags, + version: r.version, + lastEditedBy: r.lastEditedBy, createdAt: r.createdAt, updatedAt: r.updatedAt, })); @@ -711,30 +972,36 @@ const snippetDelete: ToolDef = { const parsed = SnippetDeleteInput.safeParse(args); if (!parsed.success) return err(parsed.error.message); - const deleted = await softDeleteSnippet(ctx.userId, { - name: parsed.data.name, - scope: parsed.data.scope, - projectKey: parsed.data.project, - }); - if (!deleted) return err(`snippet '${parsed.data.name}' not found`); - - await db.insert(auditLog).values({ - userId: ctx.userId, - actor: "mcp", - action: "snippet.delete", - entityType: "snippet", - entityId: deleted.id, - payload: { + const requestedKey = projectKeyOrDefault(ctx, parsed.data.project); + try { + const deleted = await softDeleteSnippet(ctx.userId, { name: parsed.data.name, - scope: deleted.scope, - projectKey: deleted.projectKey, - }, - }); + scope: parsed.data.scope, + projectKey: requestedKey, + groupNames: ctx.groups, + }); + if (!deleted) return err(`snippet '${parsed.data.name}' not found`); - return ok( - { id: deleted.id, name: parsed.data.name, deleted: true }, - `deleted snippet '${parsed.data.name}' (${deleted.scope})`, - ); + await db.insert(auditLog).values({ + userId: ctx.userId, + actor: "mcp", + action: "snippet.delete", + entityType: "snippet", + entityId: deleted.id, + payload: { + name: parsed.data.name, + scope: deleted.scope, + projectKey: deleted.projectKey, + }, + }); + + return ok( + { id: deleted.id, name: parsed.data.name, deleted: true }, + `deleted snippet '${parsed.data.name}' (${deleted.scope})`, + ); + } catch (e) { + return err(e instanceof Error ? e.message : "snippet.delete failed"); + } }, }; diff --git a/apps/web/lib/memories.ts b/apps/web/lib/memories.ts index 84dc7d0..9e17397 100644 --- a/apps/web/lib/memories.ts +++ b/apps/web/lib/memories.ts @@ -1,7 +1,8 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { db, pg } from "@/lib/db/client"; import { projects } from "@/lib/db/schema"; import { embedText } from "@/lib/embedder"; +import { readableProjectIds } from "@/lib/access"; /** * Shared search helper. Used by: @@ -11,12 +12,23 @@ import { embedText } from "@/lib/embedder"; * Performs three candidate fetches in parallel — pgvector cosine, FTS * ts_rank_cd, tag-set overlap — then fuses with Reciprocal Rank Fusion * (k=60). Returns top-N with per-source rank info attached. + * + * Sharing model: a user can see memories they OWN (user_id = U) plus + * project-scope memories under any project that's been shared with one + * of their groups (any access — ro is enough to read). The three CTEs + * extend their WHERE clauses accordingly. */ export interface SearchFilters { scope?: "project" | "user"; projectKey?: string; tags?: string[]; + /** + * Group names the requesting user is a member of. Drives shared- + * project visibility. An undefined value is treated as `[]` (no + * shared visibility) — pass through `UserContext.groups`. + */ + groupNames?: string[]; } export interface SearchHit { @@ -41,17 +53,38 @@ function toVectorLiteral(v: number[]): string { return `[${v.join(",")}]`; } -async function resolveProjectId( +async function resolveProjectIdForKey( userId: string, - projectKey?: string, + groupNames: string[], + projectKey: string, ): Promise { - if (!projectKey) return null; - const row = await db + // First check owned. Owned wins on key collision (matches + // project.identify's priority). + const owned = await db .select({ id: projects.id }) .from(projects) .where(and(eq(projects.userId, userId), eq(projects.key, projectKey))) .limit(1); - return row[0]?.id ?? null; + if (owned[0]) return owned[0].id; + + if (groupNames.length === 0) return null; + + // Then any shared project with that key. The user is allowed to read + // it; per-project authorization is enforced by the calling code's IN + // clause against `accessibleIds`. + const accessibleIds = await readableProjectIds(userId, groupNames); + if (accessibleIds.length === 0) return null; + const shared = await db + .select({ id: projects.id }) + .from(projects) + .where( + and( + eq(projects.key, projectKey), + inArray(projects.id, accessibleIds), + ), + ) + .limit(1); + return shared[0]?.id ?? null; } export async function searchMemories( @@ -60,8 +93,10 @@ export async function searchMemories( filters: SearchFilters = {}, limit = 20, ): Promise { - const { scope, projectKey, tags } = filters; - const projectId = projectKey ? await resolveProjectId(userId, projectKey) : null; + const { scope, projectKey, tags, groupNames = [] } = filters; + const projectId = projectKey + ? await resolveProjectIdForKey(userId, groupNames, projectKey) + : null; if (projectKey && !projectId) { return { hits: [], debug: { vec: 0, fts: 0, tag: 0 } }; } @@ -69,14 +104,28 @@ export async function searchMemories( const queryVec = await embedText(query); const vecLit = toVectorLiteral(queryVec); + // Build the user-visibility fragment once: rows the caller owns OR + // rows whose project_id is in the set of projects shared with this + // user's groups. When `projectId` is set we've already authorized + // that single project and can drop the fragment. + const accessibleProjectIds = projectId + ? null + : await readableProjectIds(userId, groupNames); + + // postgres-js's `${array}::uuid[]` interpolates as a Postgres array + // literal automatically. Empty array works: `= ANY('{}')` is false, + // which is the right behaviour for "no projects accessible". + const visibilityFragment = projectId + ? pg`AND project_id = ${projectId}` + : pg`AND (user_id = ${userId} OR project_id = ANY(${accessibleProjectIds ?? []}::uuid[]))`; + const vecPromise = pg<{ id: string }[]>` SELECT id FROM memories - WHERE user_id = ${userId} - AND deleted_at IS NULL + WHERE deleted_at IS NULL AND embedding IS NOT NULL ${scope ? pg`AND scope = ${scope}` : pg``} - ${projectId ? pg`AND project_id = ${projectId}` : pg``} + ${visibilityFragment} ORDER BY embedding <=> ${vecLit}::vector ASC LIMIT ${CANDIDATES} `; @@ -84,11 +133,10 @@ export async function searchMemories( const ftsPromise = pg<{ id: string }[]>` SELECT id FROM memories, plainto_tsquery('english', ${query}) AS q - WHERE user_id = ${userId} - AND deleted_at IS NULL + WHERE deleted_at IS NULL AND content_tsv @@ q ${scope ? pg`AND scope = ${scope}` : pg``} - ${projectId ? pg`AND project_id = ${projectId}` : pg``} + ${visibilityFragment} ORDER BY ts_rank_cd(content_tsv, q) DESC LIMIT ${CANDIDATES} `; @@ -98,11 +146,10 @@ export async function searchMemories( ? pg<{ id: string }[]>` SELECT id FROM memories - WHERE user_id = ${userId} - AND deleted_at IS NULL + WHERE deleted_at IS NULL AND tags && ${tags}::text[] ${scope ? pg`AND scope = ${scope}` : pg``} - ${projectId ? pg`AND project_id = ${projectId}` : pg``} + ${visibilityFragment} ORDER BY cardinality( ARRAY(SELECT unnest(tags) INTERSECT SELECT unnest(${tags}::text[])) ) DESC diff --git a/apps/web/lib/memory-actions.ts b/apps/web/lib/memory-actions.ts index 0f83dc4..5f5695d 100644 --- a/apps/web/lib/memory-actions.ts +++ b/apps/web/lib/memory-actions.ts @@ -12,6 +12,11 @@ import { MemoryUpdateInput, MemoryIdInput, } from "@shared-memory/schemas"; +import { + CONCURRENT_EDIT_ERROR, + canWriteProject, + getUserGroupNames, +} from "@/lib/access"; /** * Server Actions for memory CRUD from the Web UI. Mirrors the MCP tools @@ -19,6 +24,13 @@ import { * indistinguishable from those made via Claude Code. * * `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 { @@ -60,6 +72,7 @@ function parseTags(raw: FormDataEntryValue | null): string[] { export async function createMemoryAction(formData: FormData) { const userId = await requireUserId(); + const groupNames = await getUserGroupNames(userId); const payload = { content: String(formData.get("content") ?? "").trim(), @@ -75,7 +88,28 @@ export async function createMemoryAction(formData: FormData) { let projectId: string | null = null; if (parsed.data.scope === "project") { if (!parsed.data.project) throw new Error("scope=project requires `project`"); - projectId = await upsertProject(userId, parsed.data.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 { + const sharedRow = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.key, parsed.data.project)) + .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); @@ -89,6 +123,7 @@ export async function createMemoryAction(formData: FormData) { content: parsed.data.content, tags: parsed.data.tags ?? [], embedding, + lastEditedBy: userId, }) .returning({ id: memories.id }); @@ -111,10 +146,16 @@ export async function createMemoryAction(formData: FormData) { export async function updateMemoryAction(formData: FormData) { const userId = await requireUserId(); + const groupNames = await getUserGroupNames(userId); const id = String(formData.get("id") ?? ""); const rawScope = formData.get("scope"); const rawProject = (formData.get("project") as string | null)?.trim() || undefined; + const rawVersion = formData.get("version"); + const versionNum = + typeof rawVersion === "string" && rawVersion.length > 0 + ? Number.parseInt(rawVersion, 10) + : undefined; const payload = { id, content: ((formData.get("content") as string | null) ?? "").trim() || undefined, @@ -124,12 +165,16 @@ export async function updateMemoryAction(formData: FormData) { ? (rawScope as "project" | "user") : undefined, project: rawProject, + version: Number.isFinite(versionNum) ? versionNum : undefined, }; const parsed = MemoryUpdateInput.safeParse(payload); if (!parsed.success) { 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, @@ -137,17 +182,32 @@ export async function updateMemoryAction(formData: FormData) { 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), eq(memories.userId, userId), isNull(memories.deletedAt)), - ) + .where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt))) .limit(1); const existing = existingRows[0]; if (!existing) throw new Error("not found"); - const update: Record = { updatedAt: new Date() }; + // 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; @@ -170,9 +230,34 @@ export async function updateMemoryAction(formData: FormData) { newProjectKey = null; } } else { - // scope === 'project' — schema refine guarantees project is set + // 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!; - const projectId = await upsertProject(userId, projectKey); + let projectId: string; + const existingId = await resolveProjectId(userId, projectKey); + if (existingId) { + projectId = existingId; + } else { + // Try a shared project with this key. + const sharedRow = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.key, projectKey)) + .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; @@ -185,12 +270,27 @@ export async function updateMemoryAction(formData: FormData) { } } - await db + // 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.userId, userId))); + .where( + and( + eq(memories.id, parsed.data.id), + eq(memories.version, expectedVersion), + ), + ) + .returning({ id: memories.id }); - const auditFields = Object.keys(update).filter((k) => k !== "updatedAt"); + 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 = { @@ -219,16 +319,36 @@ export async function updateMemoryAction(formData: FormData) { export async function deleteMemoryAction(formData: FormData) { const userId = await requireUserId(); + const groupNames = await getUserGroupNames(userId); const id = String(formData.get("id") ?? ""); const parsed = MemoryIdInput.safeParse({ id }); 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, + }) + .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"); + } + const updated = await db .update(memories) - .set({ deletedAt: new Date() }) - .where( - and(eq(memories.id, parsed.data.id), eq(memories.userId, userId), isNull(memories.deletedAt)), - ) + .set({ deletedAt: new Date(), lastEditedBy: userId }) + .where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt))) .returning({ id: memories.id }); if (!updated[0]) throw new Error("not found"); diff --git a/apps/web/lib/share-actions.ts b/apps/web/lib/share-actions.ts new file mode 100644 index 0000000..a6ba4dc --- /dev/null +++ b/apps/web/lib/share-actions.ts @@ -0,0 +1,256 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { auth } from "@/auth"; +import { db } from "@/lib/db/client"; +import { + auditLog, + groups, + projects, + projectShares, + userGroups, +} from "@/lib/db/schema"; +import { MemoryAccess, ProjectKey } from "@shared-memory/schemas"; + +/** + * Server Actions for project-sharing controls. + * + * The sharing model: + * - Only the project owner can grant, change, or revoke shares. + * - The granter can only share with groups they themselves belong to. + * This prevents leaking projects to arbitrary group names from the + * OIDC IdP — you can only invite people you'd already see in the + * mirror. + * - All three actions audit-log with actor='web' so the timeline of + * access changes survives a future schema change. + * + * Inputs are read from FormData (typical Next.js Server Action surface) + * and validated with zod before any DB writes. + */ + +async function requireUserId(): Promise { + const session = await auth(); + if (!session?.user?.id) throw new Error("not authenticated"); + return session.user.id; +} + +/** + * Look up a project this user owns, by key. Returns null if it doesn't + * exist or the caller isn't the owner. Owner-gating happens here rather + * than in every action. + */ +async function resolveOwnedProject( + userId: string, + projectKey: string, +): Promise<{ id: string; key: string } | null> { + const row = await db + .select({ id: projects.id, key: projects.key }) + .from(projects) + .where(and(eq(projects.userId, userId), eq(projects.key, projectKey))) + .limit(1); + return row[0] ?? null; +} + +/** + * Resolve a group by name AS LONG AS the caller is a member. This is + * the leak-prevention check described above: an owner can't bestow + * access on a group they themselves don't have visibility into. + */ +async function resolveGrantableGroup( + userId: string, + groupName: string, +): Promise<{ id: string; name: string } | null> { + const row = await db + .select({ id: groups.id, name: groups.name }) + .from(groups) + .innerJoin(userGroups, eq(userGroups.groupId, groups.id)) + .where(and(eq(groups.name, groupName), eq(userGroups.userId, userId))) + .limit(1); + return row[0] ?? null; +} + +const AddShareInput = z.object({ + projectKey: ProjectKey, + groupName: z.string().min(1).max(200), + access: MemoryAccess, +}); + +const UpdateShareInput = z.object({ + projectKey: ProjectKey, + groupId: z.string().uuid(), + access: MemoryAccess, +}); + +const RemoveShareInput = z.object({ + projectKey: ProjectKey, + groupId: z.string().uuid(), +}); + +export async function addProjectShareAction(formData: FormData) { + const userId = await requireUserId(); + + const parsed = AddShareInput.safeParse({ + projectKey: String(formData.get("projectKey") ?? "").trim(), + groupName: String(formData.get("groupName") ?? "").trim(), + access: String(formData.get("access") ?? "ro"), + }); + if (!parsed.success) { + throw new Error(parsed.error.issues.map((i) => i.message).join("; ")); + } + + const project = await resolveOwnedProject(userId, parsed.data.projectKey); + if (!project) throw new Error("project not found or you don't own it"); + + const group = await resolveGrantableGroup(userId, parsed.data.groupName); + if (!group) { + throw new Error( + `you must be a member of group '${parsed.data.groupName}' to share with it`, + ); + } + + // Upsert: if a share already exists for (project, group), bump the + // access level. This makes the "Add share" form double as a sanity- + // safe re-grant path if a user accidentally re-adds the same group. + await db + .insert(projectShares) + .values({ + projectId: project.id, + groupId: group.id, + access: parsed.data.access, + grantedBy: userId, + }) + .onConflictDoUpdate({ + target: [projectShares.projectId, projectShares.groupId], + set: { + access: parsed.data.access, + grantedBy: userId, + grantedAt: new Date(), + }, + }); + + await db.insert(auditLog).values({ + userId, + actor: "web", + action: "project.share.add", + entityType: "project", + entityId: project.id, + payload: { + projectKey: project.key, + groupName: group.name, + access: parsed.data.access, + }, + }); + + revalidatePath(`/projects/${encodeURIComponent(project.key)}`); +} + +export async function updateProjectShareAction(formData: FormData) { + const userId = await requireUserId(); + + const parsed = UpdateShareInput.safeParse({ + projectKey: String(formData.get("projectKey") ?? "").trim(), + groupId: String(formData.get("groupId") ?? "").trim(), + access: String(formData.get("access") ?? "ro"), + }); + if (!parsed.success) { + throw new Error(parsed.error.issues.map((i) => i.message).join("; ")); + } + + const project = await resolveOwnedProject(userId, parsed.data.projectKey); + if (!project) throw new Error("project not found or you don't own it"); + + // The owner is allowed to flip any group's access — no membership + // check required (only the add path requires it; ownership is enough + // to twiddle an existing share). The row must exist. + const existing = await db + .select({ groupName: groups.name, access: projectShares.access }) + .from(projectShares) + .innerJoin(groups, eq(groups.id, projectShares.groupId)) + .where( + and( + eq(projectShares.projectId, project.id), + eq(projectShares.groupId, parsed.data.groupId), + ), + ) + .limit(1); + if (!existing[0]) throw new Error("share not found"); + + await db + .update(projectShares) + .set({ access: parsed.data.access, grantedBy: userId, grantedAt: new Date() }) + .where( + and( + eq(projectShares.projectId, project.id), + eq(projectShares.groupId, parsed.data.groupId), + ), + ); + + await db.insert(auditLog).values({ + userId, + actor: "web", + action: "project.share.update", + entityType: "project", + entityId: project.id, + payload: { + projectKey: project.key, + groupName: existing[0].groupName, + access: { from: existing[0].access, to: parsed.data.access }, + }, + }); + + revalidatePath(`/projects/${encodeURIComponent(project.key)}`); +} + +export async function removeProjectShareAction(formData: FormData) { + const userId = await requireUserId(); + + const parsed = RemoveShareInput.safeParse({ + projectKey: String(formData.get("projectKey") ?? "").trim(), + groupId: String(formData.get("groupId") ?? "").trim(), + }); + if (!parsed.success) { + throw new Error(parsed.error.issues.map((i) => i.message).join("; ")); + } + + const project = await resolveOwnedProject(userId, parsed.data.projectKey); + if (!project) throw new Error("project not found or you don't own it"); + + const existing = await db + .select({ groupName: groups.name, access: projectShares.access }) + .from(projectShares) + .innerJoin(groups, eq(groups.id, projectShares.groupId)) + .where( + and( + eq(projectShares.projectId, project.id), + eq(projectShares.groupId, parsed.data.groupId), + ), + ) + .limit(1); + if (!existing[0]) throw new Error("share not found"); + + await db + .delete(projectShares) + .where( + and( + eq(projectShares.projectId, project.id), + eq(projectShares.groupId, parsed.data.groupId), + ), + ); + + await db.insert(auditLog).values({ + userId, + actor: "web", + action: "project.share.remove", + entityType: "project", + entityId: project.id, + payload: { + projectKey: project.key, + groupName: existing[0].groupName, + access: existing[0].access, + }, + }); + + revalidatePath(`/projects/${encodeURIComponent(project.key)}`); +} diff --git a/apps/web/lib/snippet-actions.ts b/apps/web/lib/snippet-actions.ts index d29df76..d8a053f 100644 --- a/apps/web/lib/snippet-actions.ts +++ b/apps/web/lib/snippet-actions.ts @@ -10,6 +10,7 @@ import { SnippetDeleteInput, } from "@shared-memory/schemas"; import { putSnippet, softDeleteSnippet } from "@/lib/snippets"; +import { getUserGroupNames } from "@/lib/access"; /** * Server Actions for snippet CRUD from the Web UI. Mirrors the MCP @@ -17,6 +18,10 @@ import { putSnippet, softDeleteSnippet } from "@/lib/snippets"; * indistinguishable on the storage layer. * * `actor` is "web" in audit_log so we can tell the two paths apart later. + * + * Sharing: project-scope snippets under a shared project can be edited + * by any user with rw access via this path; the `putSnippet` helper + * enforces authorization and optimistic-locking concurrency control. */ async function requireUserId(): Promise { @@ -41,6 +46,7 @@ function targetUrl(scope: "project" | "user", name: string, projectKey: string | export async function createSnippetAction(formData: FormData) { const userId = await requireUserId(); + const groupNames = await getUserGroupNames(userId); const scope = (formData.get("scope") as "project" | "user") || "user"; const projectRaw = (formData.get("project") as string | null)?.trim(); @@ -65,6 +71,7 @@ export async function createSnippetAction(formData: FormData) { tags: parsed.data.tags, scope: parsed.data.scope, projectKey: parsed.data.project, + groupNames, }); await db.insert(auditLog).values({ @@ -87,11 +94,17 @@ export async function createSnippetAction(formData: FormData) { export async function updateSnippetAction(formData: FormData) { const userId = await requireUserId(); + const groupNames = await getUserGroupNames(userId); // Edits keep the row's identity (scope + name + project unchanged) — // body/description/tags are what changes. Treat as a put on the same key. const scope = (formData.get("scope") as "project" | "user") || "user"; const projectRaw = (formData.get("project") as string | null)?.trim(); + const rawVersion = formData.get("version"); + const versionNum = + typeof rawVersion === "string" && rawVersion.length > 0 + ? Number.parseInt(rawVersion, 10) + : undefined; const payload = { name: String(formData.get("name") ?? "").trim(), body: String(formData.get("body") ?? ""), @@ -99,6 +112,7 @@ export async function updateSnippetAction(formData: FormData) { tags: parseTags(formData.get("tags")), scope, project: scope === "project" ? projectRaw || undefined : undefined, + version: Number.isFinite(versionNum) ? versionNum : undefined, }; const parsed = SnippetPutInput.safeParse(payload); @@ -113,6 +127,8 @@ export async function updateSnippetAction(formData: FormData) { tags: parsed.data.tags, scope: parsed.data.scope, projectKey: parsed.data.project, + groupNames, + version: parsed.data.version, }); await db.insert(auditLog).values({ @@ -135,6 +151,7 @@ export async function updateSnippetAction(formData: FormData) { export async function deleteSnippetAction(formData: FormData) { const userId = await requireUserId(); + const groupNames = await getUserGroupNames(userId); const scope = formData.get("scope") as "project" | "user" | null; const projectRaw = (formData.get("project") as string | null)?.trim(); @@ -153,6 +170,7 @@ export async function deleteSnippetAction(formData: FormData) { name: parsed.data.name, scope: parsed.data.scope, projectKey: parsed.data.project, + groupNames, }); if (!deleted) throw new Error("not found"); diff --git a/apps/web/lib/snippets.ts b/apps/web/lib/snippets.ts index bf31d10..f05303c 100644 --- a/apps/web/lib/snippets.ts +++ b/apps/web/lib/snippets.ts @@ -1,7 +1,12 @@ -import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { snippets, projects } from "@/lib/db/schema"; import type { Snippet } from "@/lib/db/schema"; +import { + CONCURRENT_EDIT_ERROR_SNIPPET, + canWriteProject, + readableProjectIds, +} from "@/lib/access"; /** * Snippet data layer. Shared by the MCP tool handlers and the Web UI @@ -17,8 +22,14 @@ import type { Snippet } from "@/lib/db/schema"; * `scope` (+ `project` when project-scoped). When `scope` is omitted on * a get/delete, we prefer the project match (if `project` was supplied) * else fall back to the user-scope row. + * + * Sharing extends visibility: for project-scope rows, anyone who has + * read access to the project sees the snippet; rw access is required + * for putSnippet's update path and softDeleteSnippet. */ +export const CONCURRENT_EDIT_ERROR = CONCURRENT_EDIT_ERROR_SNIPPET; + export interface ResolvedScope { scope: "project" | "user"; projectId: string | null; @@ -33,6 +44,31 @@ async function resolveProjectId(userId: string, key: string): Promise { + const owned = await resolveProjectId(userId, key); + if (owned) return owned; + if (groupNames.length === 0) return null; + const readableIds = await readableProjectIds(userId, groupNames); + if (readableIds.length === 0) return null; + const row = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.key, key), inArray(projects.id, readableIds))) + .limit(1); + return row[0]?.id ?? null; +} + async function upsertProject(userId: string, key: string): Promise { const existing = await resolveProjectId(userId, key); if (existing) return existing; @@ -47,14 +83,20 @@ export interface SnippetWithProjectKey extends Snippet { projectKey: string | null; } +/** + * Look up a snippet without enforcing ownership; visibility is restricted + * by the WHERE clause to "owner" or "in a project the user can read". + * + * For user-scope snippets there's no sharing concept — they're personal. + */ async function findSnippet( userId: string, + groupNames: string[], name: string, scope: "project" | "user", projectId: string | null, ): Promise { const where = [ - eq(snippets.userId, userId), eq(snippets.name, name), eq(snippets.scope, scope), isNull(snippets.deletedAt), @@ -62,7 +104,13 @@ async function findSnippet( if (scope === "project") { if (!projectId) return null; where.push(eq(snippets.projectId, projectId)); + // Project-scope snippet: visibility = owner OR project is readable. + // The caller has already resolved `projectId` via + // `resolveVisibleProjectId`, so we only need to filter to that + // project; any row under it is by definition visible to this user. } else { + // User-scope snippet: strictly the caller's own row. + where.push(eq(snippets.userId, userId)); where.push(isNull(snippets.projectId)); } const rows = await db @@ -75,6 +123,8 @@ async function findSnippet( body: snippets.body, description: snippets.description, tags: snippets.tags, + version: snippets.version, + lastEditedBy: snippets.lastEditedBy, createdAt: snippets.createdAt, updatedAt: snippets.updatedAt, deletedAt: snippets.deletedAt, @@ -84,6 +134,9 @@ async function findSnippet( .leftJoin(projects, eq(snippets.projectId, projects.id)) .where(and(...where)) .limit(1); + // groupNames is reserved for future per-group filtering paths; for + // now project-scope visibility is already encoded by `projectId`. + void groupNames; return (rows[0] as SnippetWithProjectKey | undefined) ?? null; } @@ -91,6 +144,8 @@ async function findSnippet( * Look up a single snippet by name. If `scope` is omitted, prefers a * project match (when `projectKey` is provided) and falls back to the * user-scope row. Returns null when nothing matches. + * + * `groupNames` widens project visibility to include shared projects. */ export async function getSnippet( userId: string, @@ -98,36 +153,48 @@ export async function getSnippet( name: string; scope?: "project" | "user"; projectKey?: string; + groupNames?: string[]; }, ): Promise { - const { name, scope, projectKey } = args; + const { name, scope, projectKey, groupNames = [] } = args; if (scope === "project") { if (!projectKey) return null; - const pid = await resolveProjectId(userId, projectKey); + const pid = await resolveVisibleProjectId(userId, groupNames, projectKey); if (!pid) return null; - return findSnippet(userId, name, "project", pid); + return findSnippet(userId, groupNames, name, "project", pid); } if (scope === "user") { - return findSnippet(userId, name, "user", null); + return findSnippet(userId, groupNames, name, "user", null); } // Scope unspecified: try project first if a key was given, then user. if (projectKey) { - const pid = await resolveProjectId(userId, projectKey); + const pid = await resolveVisibleProjectId(userId, groupNames, projectKey); if (pid) { - const projectHit = await findSnippet(userId, name, "project", pid); + const projectHit = await findSnippet(userId, groupNames, name, "project", pid); if (projectHit) return projectHit; } } - return findSnippet(userId, name, "user", null); + return findSnippet(userId, groupNames, name, "user", null); } /** - * Upsert a snippet keyed by (user, scope, project, name). If the row - * already exists (live, matching scope), it's replaced in place - * preserving its id. Returns the resulting row plus an `inserted` flag. + * Upsert a snippet keyed by (scope, project, name). If a live row with + * that key already exists, it's replaced in place — preserving its id + * but bumping `version` and recording `last_edited_by`. Returns the + * resulting row plus an `inserted` flag. + * + * Authorization: + * - user-scope: only the calling user can write. + * - project-scope: caller must own the project OR have rw access. + * When the project doesn't yet exist, it's auto-upserted with the + * caller as owner (matching memory-write semantics). + * + * Optimistic locking: pass `version` to require a CAS against the + * current row's version on the update path. A 0-row update surfaces + * `CONCURRENT_EDIT_ERROR_SNIPPET`. Ignored on insert. */ export async function putSnippet( userId: string, @@ -138,29 +205,56 @@ export async function putSnippet( tags?: string[]; scope: "project" | "user"; projectKey?: string; + groupNames?: string[]; + version?: number; }, ): Promise<{ snippet: SnippetWithProjectKey; inserted: boolean }> { - const { name, body, description, tags, scope, projectKey } = args; + const { name, body, description, tags, scope, projectKey, groupNames = [], version } = args; let projectId: string | null = null; if (scope === "project") { if (!projectKey) throw new Error("scope=project requires projectKey"); - projectId = await upsertProject(userId, projectKey); + // Prefer owned; if a shared project exists with this key, require + // rw to write through it; otherwise auto-upsert (caller-owned). + const owned = await resolveProjectId(userId, projectKey); + if (owned) { + projectId = owned; + } else { + const sharedRow = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.key, projectKey)) + .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 { + projectId = await upsertProject(userId, projectKey); + } + } } - const existing = await findSnippet(userId, name, scope, projectId); + const existing = await findSnippet(userId, groupNames, name, scope, projectId); if (existing) { const updateValues: Record = { body, tags: tags ?? existing.tags, updatedAt: new Date(), + version: existing.version + 1, + lastEditedBy: userId, }; if (description !== undefined) updateValues.description = description; - await db + const expectedVersion = version ?? existing.version; + const updated = await db .update(snippets) .set(updateValues) - .where(and(eq(snippets.id, existing.id), eq(snippets.userId, userId))); - const refreshed = await findSnippet(userId, name, scope, projectId); + .where(and(eq(snippets.id, existing.id), eq(snippets.version, expectedVersion))) + .returning({ id: snippets.id }); + if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR_SNIPPET); + const refreshed = await findSnippet(userId, groupNames, name, scope, projectId); return { snippet: refreshed!, inserted: false }; } @@ -174,6 +268,7 @@ export async function putSnippet( body, description: description ?? null, tags: tags ?? [], + lastEditedBy: userId, }) .returning({ id: snippets.id }); @@ -187,6 +282,8 @@ export async function putSnippet( body: snippets.body, description: snippets.description, tags: snippets.tags, + version: snippets.version, + lastEditedBy: snippets.lastEditedBy, createdAt: snippets.createdAt, updatedAt: snippets.updatedAt, deletedAt: snippets.deletedAt, @@ -201,9 +298,13 @@ export async function putSnippet( } /** - * List live snippets for this user, newest first. Filters mirror - * memory.list. No pagination cursor yet — snippets are expected to be - * relatively low-volume; we cap at the requested limit. + * List live snippets visible to this user, newest first. Visibility: + * - user-scope rows owned by `userId` + * - project-scope rows under a project the user can read (owner or + * any group share) + * + * Filters mirror memory.list. No pagination cursor yet — snippets are + * expected to be relatively low-volume; we cap at the requested limit. */ export async function listSnippets( userId: string, @@ -212,15 +313,28 @@ export async function listSnippets( projectKey?: string; tags?: string[]; limit?: number; + groupNames?: string[]; } = {}, ): Promise { - const { scope, projectKey, tags, limit = 50 } = args; - const where = [eq(snippets.userId, userId), isNull(snippets.deletedAt)]; + const { scope, projectKey, tags, limit = 50, groupNames = [] } = args; + + // Visibility: own user-scope rows OR project-scope rows under a + // project the user can read. + const visibleProjectIds = await readableProjectIds(userId, groupNames); + const visibilityClause = + visibleProjectIds.length > 0 + ? or( + and(eq(snippets.userId, userId), isNull(snippets.projectId)), + inArray(snippets.projectId, visibleProjectIds), + ) + : and(eq(snippets.userId, userId), isNull(snippets.projectId)); + + const where = [visibilityClause!, isNull(snippets.deletedAt)]; if (scope) where.push(eq(snippets.scope, scope)); if (projectKey) { - const pid = await resolveProjectId(userId, projectKey); + const pid = await resolveVisibleProjectId(userId, groupNames, projectKey); if (!pid) return []; where.push(eq(snippets.projectId, pid)); } @@ -239,6 +353,8 @@ export async function listSnippets( body: snippets.body, description: snippets.description, tags: snippets.tags, + version: snippets.version, + lastEditedBy: snippets.lastEditedBy, createdAt: snippets.createdAt, updatedAt: snippets.updatedAt, deletedAt: snippets.deletedAt, @@ -259,6 +375,9 @@ export async function listSnippets( * * If `scope` is omitted and `projectKey` is provided, deletes the * project-scope row (if found) — falls back to user-scope otherwise. + * + * Authorization mirrors `putSnippet`: project-scope rows require rw on + * the project (or ownership); user-scope rows require ownership. */ export async function softDeleteSnippet( userId: string, @@ -266,14 +385,27 @@ export async function softDeleteSnippet( name: string; scope?: "project" | "user"; projectKey?: string; + groupNames?: string[]; }, ): Promise<{ id: string; scope: "project" | "user"; projectKey: string | null } | null> { + const { groupNames = [] } = args; const target = await getSnippet(userId, args); if (!target) return null; + // Authorize the delete. For user-scope, only the owner can delete; + // `getSnippet` already filters to the user's own user-scope row, but + // we double-check defensively in case the same name exists across + // scopes and the caller passed scope=undefined. + if (target.scope === "user") { + if (target.userId !== userId) return null; + } else if (target.projectId) { + const allowed = await canWriteProject(userId, groupNames, target.projectId); + if (!allowed) throw new Error("you don't have write access to this project"); + } + await db .update(snippets) - .set({ deletedAt: new Date() }) + .set({ deletedAt: new Date(), lastEditedBy: userId }) .where(eq(snippets.id, target.id)); return { diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index b980fb6..d7fe1f8 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -6,6 +6,11 @@ export type MemoryScope = z.infer; export const MemoryVisibility = z.enum(["private", "shared", "team"]); export type MemoryVisibility = z.infer; +// Access level a group has on a shared project. Mirrors the Postgres +// `memory_access` enum defined by Agent A's groups migration. +export const MemoryAccess = z.enum(["ro", "rw"]); +export type MemoryAccess = z.infer; + export const ProjectKey = z .string() .min(1) @@ -48,6 +53,10 @@ export const MemoryUpdateInput = z.object({ tags: Tags.optional(), scope: MemoryScope.optional(), project: ProjectKey.optional(), + // Optimistic-locking token returned by memory.get / memory.list. When + // present, the UPDATE matches on (id, version); a 0-row result means + // someone else edited this memory since you read it. + version: z.number().int().nonnegative().optional(), }) .refine( (v) => @@ -123,6 +132,9 @@ export const SnippetPutInput = z tags: Tags.optional(), scope: MemoryScope.default("user"), project: ProjectKey.optional(), + // Optimistic-locking token used on the update path (when a row with + // this name+scope+project already exists). Ignored on first put. + version: z.number().int().nonnegative().optional(), }) .refine(scopeProjectRefinement.check, { message: scopeProjectRefinement.message }); export type SnippetPutInput = z.infer;