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:
+157
-25
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user