Merge: Phase 4c+d+e project sharing + co-edit + awareness (Agent B)

# Conflicts:
#	apps/web/lib/db/schema.ts
#	apps/web/lib/mcp/context.ts
This commit is contained in:
2026-05-17 09:54:48 -07:00
16 changed files with 1885 additions and 250 deletions
+40 -4
View File
@@ -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<string>();
const memoryTotal = counts[0]?.total ?? 0;
return (
@@ -94,6 +125,11 @@ export default async function DashboardPage() {
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
{m.scope}
</Badge>
{m.projectId && sharedProjects.has(m.projectId) ? (
<Badge tone="accent" title="Shared with one or more groups">
Shared
</Badge>
) : null}
{m.projectKey ? <span>· {m.projectKey}</span> : null}
<span className="ml-auto">
{new Date(m.createdAt).toLocaleDateString()}
+74 -9
View File
@@ -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({
<Link href="/memories" className="no-underline">
<Button type="button" variant="secondary">Back</Button>
</Link>
{!isEditing ? (
{!isEditing && canWrite ? (
<Link href={`/memories/${m.id}?edit=1`} className="no-underline">
<Button>Edit</Button>
</Link>
@@ -77,19 +128,33 @@ export default async function MemoryDetailPage({
/>
<Card className="mb-4">
<CardHeader className="flex items-center gap-2 text-xs text-fg-muted">
<CardHeader className="flex items-center gap-2 text-xs text-fg-muted flex-wrap">
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>{m.scope}</Badge>
{m.projectKey ? <span className="font-mono">{m.projectKey}</span> : null}
{shareRows.length > 0 ? (
<Badge
tone="accent"
title={`Shared with ${shareRows.map((s) => s.groupName).join(", ")}`}
>
Shared
</Badge>
) : null}
<span>· Created {new Date(m.createdAt).toLocaleString()}</span>
{m.updatedAt.getTime() !== m.createdAt.getTime() ? (
<span>· Updated {new Date(m.updatedAt).toLocaleString()}</span>
) : null}
{editorLabel && m.lastEditedBy !== m.ownerUserId ? (
<span className="text-fg-subtle">
· Last edited by {editorLabel}
</span>
) : null}
</CardHeader>
{isEditing ? (
<CardBody>
<form action={updateMemoryAction} className="space-y-4">
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="version" value={m.version} />
<div>
<Label htmlFor="scope">Scope</Label>
<select
@@ -168,7 +233,7 @@ export default async function MemoryDetailPage({
)}
</Card>
{!isEditing ? (
{!isEditing && canWrite ? (
<form action={deleteMemoryAction} className="flex justify-end">
<input type="hidden" name="id" value={m.id} />
<Button type="submit" variant="danger" size="sm">
+68 -11
View File
@@ -1,9 +1,10 @@
import Link from "next/link";
import { and, desc, eq, isNull, inArray, sql } from "drizzle-orm";
import { and, desc, eq, isNull, inArray, or, sql } 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 { searchMemories } from "@/lib/memories";
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
@@ -18,15 +19,16 @@ type Scope = "project" | "user";
interface MemoryRow {
id: string;
scope: "project" | "user";
projectId: string | null;
projectKey: string | null;
content: string;
tags: string[];
createdAt: Date;
rank?: { rrfScore: number; vectorRank: number | null; ftsRank: number | null; tagRank: number | null };
shared?: boolean;
}
async function fetchMemoriesByIds(
userId: string,
ids: string[],
): Promise<Map<string, MemoryRow>> {
if (ids.length === 0) return new Map();
@@ -37,22 +39,39 @@ async function fetchMemoriesByIds(
content: memories.content,
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), inArray(memories.id, ids), isNull(memories.deletedAt)));
.where(and(inArray(memories.id, ids), isNull(memories.deletedAt)));
return new Map(rows.map((r) => [r.id, r as MemoryRow]));
}
async function listRecent(userId: string, scope?: Scope, project?: string): Promise<MemoryRow[]> {
const filters = [eq(memories.userId, userId), isNull(memories.deletedAt)];
async function listRecent(
userId: string,
groupNames: string[],
scope?: Scope,
project?: string,
): Promise<MemoryRow[]> {
// Visibility: own rows OR rows in an accessible project.
const accessibleIds = await readableProjectIds(userId, groupNames);
const visibility =
accessibleIds.length > 0
? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds))
: eq(memories.userId, userId);
const filters = [visibility!, isNull(memories.deletedAt)];
if (scope) filters.push(eq(memories.scope, scope));
if (project) {
// Project filter — match the project key against any project the
// user can read (owned or shared). When the key matches none of
// those, return empty.
filters.push(
sql`${memories.projectId} = (
sql`${memories.projectId} IN (
SELECT id FROM ${projects}
WHERE ${projects.userId} = ${userId} AND ${projects.key} = ${project}
WHERE ${projects.key} = ${project}
AND (${projects.userId} = ${userId}
OR ${projects.id} = ANY(${accessibleIds}::uuid[]))
)`,
);
}
@@ -63,6 +82,7 @@ async function listRecent(userId: string, scope?: Scope, project?: string): Prom
content: memories.content,
tags: memories.tags,
createdAt: memories.createdAt,
projectId: memories.projectId,
projectKey: projects.key,
})
.from(memories)
@@ -73,6 +93,20 @@ async function listRecent(userId: string, scope?: Scope, project?: string): Prom
return rows;
}
/**
* Lookup which projects in `projectIds` have any share rows. Used so
* we can show a "Shared" chip per memory card. One query covers every
* row on the page; per-row inspection would be N+1 here.
*/
async function sharedProjectSet(projectIds: string[]): Promise<Set<string>> {
if (projectIds.length === 0) return new Set();
const rows = await db
.selectDistinct({ projectId: projectShares.projectId })
.from(projectShares)
.where(inArray(projectShares.projectId, projectIds));
return new Set(rows.map((r) => r.projectId));
}
export default async function MemoriesPage({
searchParams,
}: {
@@ -80,6 +114,7 @@ export default async function MemoriesPage({
}) {
const session = await auth();
const userId = session!.user.id;
const groupNames = await getUserGroupNames(userId);
const params = await searchParams;
const q = params.q?.trim() || undefined;
const scope = params.scope === "user" || params.scope === "project" ? params.scope : undefined;
@@ -89,18 +124,35 @@ export default async function MemoriesPage({
let debug: { vec: number; fts: number; tag: number } | null = null;
if (q) {
const result = await searchMemories(userId, q, { scope, projectKey: project }, 30);
const result = await searchMemories(
userId,
q,
{ scope, projectKey: project, groupNames },
30,
);
const ids = result.hits.map((h) => h.id);
const byId = await fetchMemoriesByIds(userId, ids);
const byId = await fetchMemoriesByIds(ids);
rows = result.hits.flatMap((h) => {
const r = byId.get(h.id);
return r ? [{ ...r, rank: h.rank }] : [];
});
debug = result.debug;
} else {
rows = await listRecent(userId, scope, project);
rows = await listRecent(userId, groupNames, scope, project);
}
// Annotate which rows belong to projects that have any active share.
// Done in a single query so the listing stays O(1) DB calls regardless
// of page size.
const projectIds = rows
.map((r) => r.projectId)
.filter((p): p is string => p !== null);
const sharedProjects = await sharedProjectSet(projectIds);
rows = rows.map((r) => ({
...r,
shared: r.projectId ? sharedProjects.has(r.projectId) : false,
}));
return (
<Container className="pt-6">
<PageHeader
@@ -168,6 +220,11 @@ export default async function MemoriesPage({
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
{m.scope}
</Badge>
{m.shared ? (
<Badge tone="accent" title="Shared with one or more groups">
Shared
</Badge>
) : null}
{m.projectKey ? (
<span className="font-mono">· {m.projectKey}</span>
) : null}
+278 -30
View File
@@ -1,17 +1,48 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { and, desc, eq, isNull } from "drizzle-orm";
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { memories, projects } from "@/lib/db/schema";
import {
groups,
memories,
projects,
projectShares,
users,
} from "@/lib/db/schema";
import {
getProjectAccess,
getUserGroupNames,
readableProjectIds,
} from "@/lib/access";
import {
addProjectShareAction,
removeProjectShareAction,
updateProjectShareAction,
} from "@/lib/share-actions";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody } from "@/app/_components/ui/card";
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
import { Button } from "@/app/_components/ui/button";
import { Input, Label } from "@/app/_components/ui/input";
import { EmptyState } from "@/app/_components/ui/empty-state";
export const dynamic = "force-dynamic";
/**
* Project detail page.
*
* Three personas converge here:
* - Owner viewing their own project: full memory list + share-
* management UI.
* - Member of a group with rw access: same memory list, can edit
* memories, but cannot edit shares.
* - Member with ro access: memory list rendered read-only-ish; no
* "New memory" button.
*
* Authorization is centralised in `lib/access.ts` so this page only
* has to ask "what's my access level" once and branch on the answer.
*/
export default async function ProjectDetailPage({
params,
}: {
@@ -21,15 +52,66 @@ export default async function ProjectDetailPage({
const userId = session!.user.id;
const { key: rawKey } = await params;
const key = decodeURIComponent(rawKey);
const groupNames = await getUserGroupNames(userId);
const projectRow = await db
// Resolve the project. Prefer an owned project; otherwise look for a
// shared project with this key the user can read. Mirrors the
// MCP-side project.identify priority.
const ownedRow = await db
.select()
.from(projects)
.where(and(eq(projects.userId, userId), eq(projects.key, key)))
.limit(1);
const project = projectRow[0];
if (!project) notFound();
let project = ownedRow[0];
if (!project) {
if (groupNames.length === 0) notFound();
const readableIds = await readableProjectIds(userId, groupNames);
if (readableIds.length === 0) notFound();
const sharedRow = await db
.select()
.from(projects)
.where(and(eq(projects.key, key), inArray(projects.id, readableIds)))
.limit(1);
if (!sharedRow[0]) notFound();
project = sharedRow[0];
}
const access = await getProjectAccess(userId, groupNames, project.id);
if (access === null) notFound();
const isOwner = access === "owner";
const canWrite = access === "owner" || access === "rw";
// Owner display name for the page header. When the viewer IS the
// owner we just say "Owned by you"; otherwise look up the owner.
let ownerDisplayName: string | null = null;
if (!isOwner) {
const ownerRow = await db
.select({ name: users.name, email: users.email })
.from(users)
.where(eq(users.id, project.userId))
.limit(1);
ownerDisplayName = ownerRow[0]?.name ?? ownerRow[0]?.email ?? "another user";
}
// All shares on this project, regardless of viewer's group memberships
// — the owner needs to see everything; non-owners see the same list
// for situational awareness.
const shareRows = await db
.select({
groupId: groups.id,
groupName: groups.name,
access: projectShares.access,
grantedAt: projectShares.grantedAt,
})
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.where(eq(projectShares.projectId, project.id))
.orderBy(groups.name);
// Memories: visible to owner + members alike — anyone with read
// access on the project sees every memory under it. The query is
// unchanged from the pre-sharing version; project_id is the gate.
const mem = await db
.select({
id: memories.id,
@@ -39,53 +121,211 @@ export default async function ProjectDetailPage({
createdAt: memories.createdAt,
})
.from(memories)
.where(
and(
eq(memories.userId, userId),
eq(memories.projectId, project.id),
isNull(memories.deletedAt),
),
)
.where(and(eq(memories.projectId, project.id), isNull(memories.deletedAt)))
.orderBy(desc(memories.createdAt))
.limit(100);
// Groups the viewer is a member of — drives the share-add datalist
// for owners (only show groups they could plausibly invite). Returns
// an empty list when the user has no group memberships so the
// datalist is simply absent rather than emitting a broken IN ().
const myGroups =
groupNames.length > 0
? await db
.select({
id: groups.id,
name: groups.name,
displayName: groups.displayName,
})
.from(groups)
.where(inArray(groups.name, groupNames))
.limit(50)
: [];
return (
<Container className="pt-6">
<PageHeader
title={project.displayName ?? project.key}
description={
<>
<span className="flex items-center gap-2 flex-wrap">
<span className="font-mono">{project.key}</span>
{" · "}
{mem.length} memor{mem.length === 1 ? "y" : "ies"}
</>
<span>·</span>
<span>
{mem.length} memor{mem.length === 1 ? "y" : "ies"}
</span>
<span>·</span>
{isOwner ? (
<Badge tone="success">Owned by you</Badge>
) : (
<span className="text-fg-subtle">Owned by {ownerDisplayName}</span>
)}
{shareRows.length > 0 ? (
<>
<span>·</span>
<Badge tone="accent">
Shared with {shareRows.length} group
{shareRows.length === 1 ? "" : "s"}
</Badge>
</>
) : null}
{!isOwner ? (
<>
<span>·</span>
<Badge tone={access === "rw" ? "success" : "neutral"}>
{access === "rw" ? "read + write" : "read only"}
</Badge>
</>
) : null}
</span>
}
actions={
<>
<Link href="/projects" className="no-underline">
<Button type="button" variant="secondary">All projects</Button>
</Link>
<Link
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
className="no-underline"
>
<Button>New in this project</Button>
</Link>
{canWrite ? (
<Link
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
className="no-underline"
>
<Button>New in this project</Button>
</Link>
) : null}
</>
}
/>
{shareRows.length > 0 || isOwner ? (
<Card className="mb-6">
<CardHeader className="flex items-center justify-between">
<span className="text-sm font-medium">Sharing</span>
<span className="text-xs text-fg-subtle">
{shareRows.length === 0
? "No groups have access"
: `${shareRows.length} group${shareRows.length === 1 ? "" : "s"}`}
</span>
</CardHeader>
<CardBody className="space-y-3">
{shareRows.length === 0 && !isOwner ? (
<p className="text-sm text-fg-subtle">Only the owner has access.</p>
) : null}
{shareRows.length > 0 ? (
<ul className="divide-y divide-border">
{shareRows.map((s) => (
<li
key={s.groupId}
className="flex items-center gap-3 py-2 text-sm"
>
<Badge tone="accent">{s.groupName}</Badge>
<Badge tone={s.access === "rw" ? "success" : "neutral"}>
{s.access}
</Badge>
<span className="text-xs text-fg-subtle">
since {new Date(s.grantedAt).toLocaleDateString()}
</span>
{isOwner ? (
<div className="ml-auto flex items-center gap-1">
<form action={updateProjectShareAction}>
<input type="hidden" name="projectKey" value={project.key} />
<input type="hidden" name="groupId" value={s.groupId} />
<input
type="hidden"
name="access"
value={s.access === "rw" ? "ro" : "rw"}
/>
<Button
type="submit"
variant="secondary"
size="sm"
title={
s.access === "rw"
? "Downgrade to read-only"
: "Promote to read-write"
}
>
{s.access === "rw" ? "→ ro" : "→ rw"}
</Button>
</form>
<form action={removeProjectShareAction}>
<input type="hidden" name="projectKey" value={project.key} />
<input type="hidden" name="groupId" value={s.groupId} />
<Button type="submit" variant="danger" size="sm">
Remove
</Button>
</form>
</div>
) : null}
</li>
))}
</ul>
) : null}
{isOwner ? (
<form
action={addProjectShareAction}
className="flex flex-wrap items-end gap-2 pt-2 border-t border-border"
>
<input type="hidden" name="projectKey" value={project.key} />
<div className="flex-1 min-w-[200px]">
<Label htmlFor="groupName" hint="must be a group you're a member of">
Group name
</Label>
<Input
id="groupName"
name="groupName"
list="my-group-list"
placeholder="engineering"
required
className="mt-1"
/>
{myGroups.length > 0 ? (
<datalist id="my-group-list">
{myGroups.map((g) => (
<option key={g.id} value={g.name}>
{g.displayName ?? g.name}
</option>
))}
</datalist>
) : null}
</div>
<div>
<Label>Access</Label>
<div className="mt-1 flex items-center gap-3 h-9">
<label className="text-sm flex items-center gap-1">
<input type="radio" name="access" value="ro" defaultChecked />
ro
</label>
<label className="text-sm flex items-center gap-1">
<input type="radio" name="access" value="rw" />
rw
</label>
</div>
</div>
<Button type="submit">Add share</Button>
</form>
) : null}
</CardBody>
</Card>
) : null}
{mem.length === 0 ? (
<EmptyState
title="No memories in this project yet"
description="Use the MCP from a Claude Code session, or create one here."
description={
canWrite
? "Use the MCP from a Claude Code session, or create one here."
: "Members with write access can add memories from the MCP or the Web UI."
}
action={
<Link
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
className="no-underline"
>
<Button>Create the first one</Button>
</Link>
canWrite ? (
<Link
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
className="no-underline"
>
<Button>Create the first one</Button>
</Link>
) : null
}
/>
) : (
@@ -99,6 +339,14 @@ export default async function ProjectDetailPage({
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
{m.scope}
</Badge>
{shareRows.length > 0 ? (
<Badge
tone="accent"
title={`Shared with ${shareRows.map((s) => s.groupName).join(", ")}`}
>
Shared
</Badge>
) : null}
<span>{new Date(m.createdAt).toLocaleString()}</span>
</div>
<p className="text-sm text-fg line-clamp-3">{m.content}</p>
+57 -8
View File
@@ -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<SiblingHit[]> {
async function findAllMatches(
userId: string,
groupNames: string[],
name: string,
): Promise<SiblingHit[]> {
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<SiblingHit[
})
.from(snippets)
.leftJoin(projects, eq(snippets.projectId, projects.id))
.where(and(eq(snippets.userId, userId), eq(snippets.name, name), isNull(snippets.deletedAt)));
.where(and(eq(snippets.name, name), isNull(snippets.deletedAt), visibility!));
return rows as SiblingHit[];
}
@@ -45,15 +63,16 @@ export default async function SnippetDetailPage({
}) {
const session = await auth();
const userId = session!.user.id;
const groupNames = await getUserGroupNames(userId);
const { name: rawName } = await params;
const name = decodeURIComponent(rawName);
const sp = await searchParams;
const scope: "project" | "user" | undefined =
sp.scope === "user" || sp.scope === "project" ? sp.scope : undefined;
const project = sp.project?.trim() || undefined;
const isEditing = sp.edit === "1";
const wantsEdit = sp.edit === "1";
const siblings = await findAllMatches(userId, name);
const siblings = await findAllMatches(userId, groupNames, name);
if (siblings.length === 0) notFound();
// If multiple matches and the user hasn't disambiguated, show a picker.
@@ -103,10 +122,36 @@ export default async function SnippetDetailPage({
name,
scope,
projectKey: project,
groupNames,
});
if (!snippet) notFound();
// Authorize: user-scope rows belong solely to their owner; project-
// scope rows require rw on the project (or ownership) to edit.
let canWrite: boolean;
if (snippet.scope === "user") {
canWrite = snippet.userId === userId;
} else if (snippet.projectId) {
const access = await getProjectAccess(userId, groupNames, snippet.projectId);
canWrite = access === "owner" || access === "rw";
} else {
canWrite = false;
}
const isEditing = wantsEdit && canWrite;
// Editor name for "Last edited by ..." footer.
const editorRow = snippet.lastEditedBy
? await db
.select({ name: users.name, email: users.email })
.from(users)
.where(eq(users.id, snippet.lastEditedBy))
.limit(1)
: [];
const editorLabel = editorRow[0]
? editorRow[0].name ?? editorRow[0].email ?? "unknown"
: null;
return (
<Container className="pt-6 max-w-3xl">
<PageHeader
@@ -124,7 +169,7 @@ export default async function SnippetDetailPage({
Back
</Button>
</Link>
{!isEditing ? (
{!isEditing && canWrite ? (
<Link
href={`/snippets/${encodeURIComponent(snippet.name)}?${new URLSearchParams({
scope: snippet.scope,
@@ -150,6 +195,9 @@ export default async function SnippetDetailPage({
{snippet.updatedAt.getTime() !== snippet.createdAt.getTime() ? (
<span>· Updated {new Date(snippet.updatedAt).toLocaleString()}</span>
) : null}
{editorLabel && snippet.lastEditedBy !== snippet.userId ? (
<span className="text-fg-subtle">· Last edited by {editorLabel}</span>
) : null}
</CardHeader>
{isEditing ? (
@@ -157,6 +205,7 @@ export default async function SnippetDetailPage({
<form action={updateSnippetAction} className="space-y-4">
<input type="hidden" name="name" value={snippet.name} />
<input type="hidden" name="scope" value={snippet.scope} />
<input type="hidden" name="version" value={snippet.version} />
{snippet.scope === "project" && snippet.projectKey ? (
<input type="hidden" name="project" value={snippet.projectKey} />
) : null}
@@ -234,7 +283,7 @@ export default async function SnippetDetailPage({
)}
</Card>
{!isEditing ? (
{!isEditing && canWrite ? (
<form action={deleteSnippetAction} className="flex justify-end">
<input type="hidden" name="name" value={snippet.name} />
<input type="hidden" name="scope" value={snippet.scope} />
+64
View File
@@ -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;
+210
View File
@@ -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<AccessibleProject[]> {
const owned = await db
.select({ projectId: projects.id, projectKey: projects.key })
.from(projects)
.where(eq(projects.userId, userId));
const ownedMap = new Map<string, AccessibleProject>(
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<ProjectAccess | null> {
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<boolean> {
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<boolean> {
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<string[]> {
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<string[]> {
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<string[]> {
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);
}
+55 -6
View File
@@ -11,6 +11,7 @@ import {
customType,
vector,
varchar,
integer,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
@@ -38,9 +39,8 @@ 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"]);
// Reserved here so 0003 (this file's matching migration) owns it. Used
// by Agent B's upcoming `project_shares` table to express RO vs RW
// grants per shared group.
// Created by 0003_groups.sql; declared here so the TS layer (notably
// `project_shares`) can reference it as a typed pgEnum.
export const memoryAccess = pgEnum("memory_access", ["ro", "rw"]);
// ---------- tables ----------
@@ -49,7 +49,7 @@ export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
// OIDC `sub` claim from Authentik — stable identifier for this user.
// OIDC `sub` claim from the IdP — stable identifier for this user.
oidcSub: text("oidc_sub").notNull(),
// OIDC `iss` so we can disambiguate if we ever federate.
oidcIss: text("oidc_iss").notNull(),
@@ -99,6 +99,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 }),
@@ -126,6 +134,10 @@ export const snippets = pgTable(
body: text("body").notNull(),
description: text("description"),
tags: textArray("tags").notNull().default([]),
// See `memories.version` / `memories.lastEditedBy` — co-edit primitive
// for snippets in shared projects.
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 }),
@@ -157,6 +169,14 @@ export const cliTokens = pgTable(
}),
);
// ---------- groups + sharing ----------
//
// `groups` and `user_groups` come from `0003_groups.sql`; `project_shares`
// comes from `0004_project_shares.sql`. Drizzle declarations here let
// authorization helpers and the share-management UI import everything
// through `@/lib/db/schema`. Column shape MUST stay in lockstep with the
// migrations.
export const groups = pgTable(
"groups",
{
@@ -164,9 +184,8 @@ export const groups = pgTable(
// OIDC issuer this group originates from — pairs with `name` so two
// IdPs can both have a "platform" group without collision.
oidcIss: text("oidc_iss").notNull(),
// Group `name` as it appears in the JWT (Authentik / EntraID groups claim).
name: text("name").notNull(),
// Optional human-friendly label. Most IdPs only emit names, so this
// is usually NULL; reserved for future enrichment.
displayName: text("display_name"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
@@ -191,6 +210,34 @@ export const userGroups = pgTable(
(t) => ({
pk: primaryKey({ columns: [t.userId, t.groupId] }),
userIdx: index("user_groups_user_idx").on(t.userId),
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.
//
// Owners share projects from the Web UI; the MCP layer can resolve
// 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),
}),
);
@@ -234,3 +281,5 @@ 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;
+7 -2
View File
@@ -8,6 +8,12 @@ import type { AuthenticatedClaims } from "@/lib/auth/jwt";
*
* Resolves (or creates) the internal `users` row from the OIDC claims so
* tools work with stable UUID foreign keys rather than raw `sub` strings.
*
* `groups` and `defaultProjectKey` are populated here from the inbound
* request: groups come from the JWT's `groups` claim (live) with a DB
* fallback for CLI tokens that carry no claim; defaultProjectKey is the
* `X-Project-Key` header (already Zod-validated at the route boundary),
* used as a fallback when a tool call omits `project`.
*/
export interface UserContext {
/** Internal users.id UUID. */
@@ -85,8 +91,7 @@ export async function userContextFromClaims(
// emit it). CLI tokens never do — they go through verifyCliToken which
// doesn't set claims.groups. In that case fall back to the DB snapshot
// from the user's last interactive sign-in.
const groupNames =
claims.groups ?? (await loadUserGroups(userId));
const groupNames = claims.groups ?? (await loadUserGroups(userId));
return {
userId,
+390 -123
View File
@@ -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<string | null> {
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;
}
/**
@@ -114,7 +155,7 @@ function withDefaultProject(
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: {
@@ -134,20 +175,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,
@@ -155,22 +297,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 (164,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",
@@ -191,11 +345,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}'`);
}
}
@@ -214,6 +377,7 @@ const memoryWrite: ToolDef = {
content: parsed.data.content,
tags: parsed.data.tags ?? [],
embedding,
lastEditedBy: ctx.userId,
})
.returning({ id: memories.id, createdAt: memories.createdAt });
@@ -224,7 +388,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}`);
@@ -252,12 +416,20 @@ const memoryList: ToolDef = {
const parsed = MemoryListInput.safeParse(withDefaultProject(args, ctx));
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));
}
@@ -273,6 +445,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,
})
@@ -301,17 +475,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}`);
},
};
@@ -328,16 +506,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");
@@ -357,7 +553,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: {
@@ -375,6 +571,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"],
},
@@ -389,21 +591,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<string, unknown> = { 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<string, unknown> = {
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;
@@ -426,12 +636,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;
@@ -444,13 +659,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<string, unknown> = { fields: auditFields };
if (scopeChanged || projectChanged) {
auditPayload.scope = {
@@ -496,17 +725,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,
);
@@ -522,6 +750,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,
})
@@ -549,7 +779,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: {
@@ -570,17 +800,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"],
},
@@ -590,48 +827,64 @@ const snippetPut: ToolDef = {
const parsed = SnippetPutInput.safeParse(withDefaultProject(args, ctx, "user"));
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");
}
},
};
@@ -661,10 +914,12 @@ const snippetGet: ToolDef = {
const parsed = SnippetGetInput.safeParse(withDefaultProject(args, ctx));
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`);
@@ -678,6 +933,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,
},
@@ -710,11 +967,13 @@ const snippetList: ToolDef = {
const parsed = SnippetListInput.safeParse(withDefaultProject(args, ctx));
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) => ({
@@ -724,6 +983,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,
}));
@@ -749,30 +1010,36 @@ const snippetDelete: ToolDef = {
const parsed = SnippetDeleteInput.safeParse(withDefaultProject(args, ctx));
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");
}
},
};
+64 -17
View File
@@ -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<string | null> {
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<SearchResult> {
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
+135 -15
View File
@@ -7,12 +7,17 @@ import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { memories, projects, auditLog } from "@/lib/db/schema";
import { embedText } from "@/lib/embedder";
import { upsertProject } from "@/lib/projects";
import { resolveProjectId, upsertProject } from "@/lib/projects";
import {
MemoryWriteInput,
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
@@ -20,6 +25,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<string> {
@@ -38,6 +50,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(),
@@ -53,7 +66,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);
@@ -67,6 +101,7 @@ export async function createMemoryAction(formData: FormData) {
content: parsed.data.content,
tags: parsed.data.tags ?? [],
embedding,
lastEditedBy: userId,
})
.returning({ id: memories.id });
@@ -89,10 +124,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,
@@ -102,12 +143,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,
@@ -115,17 +160,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<string, unknown> = { 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<string, unknown> = {
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;
@@ -148,9 +208,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;
@@ -163,12 +248,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<string, unknown> = { fields: auditFields };
if (scopeChanged || projectChanged) {
auditPayload.scope = {
@@ -197,16 +297,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");
+256
View File
@@ -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<string> {
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)}`);
}
+18
View File
@@ -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<string> {
@@ -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");
+157 -25
View File
@@ -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<string | n
return row[0]?.id ?? null;
}
/**
* Resolve a project id by key, preferring an owned project, falling
* back to a shared project the user can read. Returns null if the key
* matches nothing visible. Used by snippet lookups (which need to find
* project-scope snippets under shared projects) — write authorization
* is enforced separately by the caller via `canWriteProject`.
*/
async function resolveVisibleProjectId(
userId: string,
groupNames: string[],
key: string,
): Promise<string | null> {
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<string> {
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<SnippetWithProjectKey | null> {
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<SnippetWithProjectKey | null> {
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<string, unknown> = {
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<SnippetWithProjectKey[]> {
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 {
+12
View File
@@ -6,6 +6,11 @@ export type MemoryScope = z.infer<typeof MemoryScope>;
export const MemoryVisibility = z.enum(["private", "shared", "team"]);
export type MemoryVisibility = z.infer<typeof MemoryVisibility>;
// 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<typeof MemoryAccess>;
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<typeof SnippetPutInput>;