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:
+64
-17
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user