feat(sharing): project shares, co-edit safety, awareness UI (Phase 4c+d+e)
Adds project-level sharing via the new project_shares table plus the
infrastructure that makes multi-user editing safe and visible.
Authorization (lib/access.ts):
- getAccessibleProjects / getProjectAccess centralise the predicate
used by every read and write path.
- readableProjectIds / writableProjectIds drive listing-style queries.
- Web UI Server Actions and pages source group memberships from the
user_groups table so authorization works without depending on
Agent A's session callback shape.
Optimistic locking:
- memories + snippets gain version + last_edited_by columns. Every
UPDATE bumps version and stamps the editor; UPDATE WHERE clauses
require the caller's pre-fetched version, surfacing a clear
"refresh and try again" error on lost-write races rather than
silently clobbering.
- MemoryUpdateInput / SnippetPutInput accept an optional version
token.
MCP tools:
- memory.write / .update / .delete / .get / .list / .search,
snippet.put / .get / .list / .delete now respect shared-project
access (read = owner | any share, write = owner | rw share).
- project, defaults to ctx.defaultProjectKey from the X-Project-Key
header (populated by the MCP route — Agent A's wiring).
- project.identify returns shared projects you have access to and
prefers an owned project on key collision, audit-logging the
collision so an operator can debug it.
- Tool descriptions for memory.update, memory.write, snippet.put,
and project.identify updated with the co-edit / shared-project
notes.
Web UI:
- Project detail page: ownership badge, shared-with-N-groups badge,
owner-only "Manage sharing" section (add/flip/remove shares via
lib/share-actions.ts). Add-share is constrained to groups the
granter is already in.
- "Shared" chips on memory cards in /memories and /dashboard.
- "Last edited by ..." on memory + snippet detail pages, shown only
when the last editor isn't the row's original author so the chip
stays informative.
- Read-only viewers (ro shares) lose Edit/Delete affordances on
memories and snippets.
Migration 0004_project_shares.sql adds project_shares + the two new
columns on memories and snippets; it depends on Agent A's
0003_groups.sql for the groups, user_groups, and memory_access enum.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user