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:
@@ -1,8 +1,9 @@
|
|||||||
import Link from "next/link";
|
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 { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db/client";
|
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 { Container, PageHeader } from "@/app/_components/ui/container";
|
||||||
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
||||||
import { Badge } from "@/app/_components/ui/badge";
|
import { Badge } from "@/app/_components/ui/badge";
|
||||||
@@ -14,14 +15,27 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const userId = session!.user.id;
|
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([
|
const [counts, recent, topProjects] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
total: count(memories.id),
|
total: count(memories.id),
|
||||||
})
|
})
|
||||||
.from(memories)
|
.from(memories)
|
||||||
.where(and(eq(memories.userId, userId), isNull(memories.deletedAt))),
|
.where(and(visibility!, isNull(memories.deletedAt))),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: memories.id,
|
id: memories.id,
|
||||||
@@ -29,11 +43,12 @@ export default async function DashboardPage() {
|
|||||||
scope: memories.scope,
|
scope: memories.scope,
|
||||||
tags: memories.tags,
|
tags: memories.tags,
|
||||||
createdAt: memories.createdAt,
|
createdAt: memories.createdAt,
|
||||||
|
projectId: memories.projectId,
|
||||||
projectKey: projects.key,
|
projectKey: projects.key,
|
||||||
})
|
})
|
||||||
.from(memories)
|
.from(memories)
|
||||||
.leftJoin(projects, eq(memories.projectId, projects.id))
|
.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))
|
.orderBy(desc(memories.createdAt))
|
||||||
.limit(5),
|
.limit(5),
|
||||||
db
|
db
|
||||||
@@ -54,6 +69,22 @@ export default async function DashboardPage() {
|
|||||||
.limit(4),
|
.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;
|
const memoryTotal = counts[0]?.total ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,6 +125,11 @@ export default async function DashboardPage() {
|
|||||||
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
|
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
|
||||||
{m.scope}
|
{m.scope}
|
||||||
</Badge>
|
</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}
|
{m.projectKey ? <span>· {m.projectKey}</span> : null}
|
||||||
<span className="ml-auto">
|
<span className="ml-auto">
|
||||||
{new Date(m.createdAt).toLocaleDateString()}
|
{new Date(m.createdAt).toLocaleDateString()}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
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 { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db/client";
|
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 { updateMemoryAction, deleteMemoryAction } from "@/lib/memory-actions";
|
||||||
|
import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||||
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
||||||
import { Input, Textarea, Label } from "@/app/_components/ui/input";
|
import { Input, Textarea, Label } from "@/app/_components/ui/input";
|
||||||
@@ -22,31 +23,81 @@ export default async function MemoryDetailPage({
|
|||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const userId = session!.user.id;
|
const userId = session!.user.id;
|
||||||
|
const groupNames = await getUserGroupNames(userId);
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { edit } = await searchParams;
|
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
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: memories.id,
|
id: memories.id,
|
||||||
scope: memories.scope,
|
scope: memories.scope,
|
||||||
content: memories.content,
|
content: memories.content,
|
||||||
tags: memories.tags,
|
tags: memories.tags,
|
||||||
|
version: memories.version,
|
||||||
|
lastEditedBy: memories.lastEditedBy,
|
||||||
createdAt: memories.createdAt,
|
createdAt: memories.createdAt,
|
||||||
updatedAt: memories.updatedAt,
|
updatedAt: memories.updatedAt,
|
||||||
projectKey: projects.key,
|
projectKey: projects.key,
|
||||||
projectId: memories.projectId,
|
projectId: memories.projectId,
|
||||||
|
ownerUserId: memories.userId,
|
||||||
})
|
})
|
||||||
.from(memories)
|
.from(memories)
|
||||||
.leftJoin(projects, eq(memories.projectId, projects.id))
|
.leftJoin(projects, eq(memories.projectId, projects.id))
|
||||||
.where(
|
.where(and(eq(memories.id, id), isNull(memories.deletedAt), visibility!))
|
||||||
and(eq(memories.id, id), eq(memories.userId, userId), isNull(memories.deletedAt)),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const m = rows[0];
|
const m = rows[0];
|
||||||
if (!m) notFound();
|
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
|
const projectList = isEditing
|
||||||
? await db
|
? await db
|
||||||
@@ -67,7 +118,7 @@ export default async function MemoryDetailPage({
|
|||||||
<Link href="/memories" className="no-underline">
|
<Link href="/memories" className="no-underline">
|
||||||
<Button type="button" variant="secondary">Back</Button>
|
<Button type="button" variant="secondary">Back</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{!isEditing ? (
|
{!isEditing && canWrite ? (
|
||||||
<Link href={`/memories/${m.id}?edit=1`} className="no-underline">
|
<Link href={`/memories/${m.id}?edit=1`} className="no-underline">
|
||||||
<Button>Edit</Button>
|
<Button>Edit</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -77,19 +128,33 @@ export default async function MemoryDetailPage({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="mb-4">
|
<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>
|
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>{m.scope}</Badge>
|
||||||
{m.projectKey ? <span className="font-mono">{m.projectKey}</span> : null}
|
{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>
|
<span>· Created {new Date(m.createdAt).toLocaleString()}</span>
|
||||||
{m.updatedAt.getTime() !== m.createdAt.getTime() ? (
|
{m.updatedAt.getTime() !== m.createdAt.getTime() ? (
|
||||||
<span>· Updated {new Date(m.updatedAt).toLocaleString()}</span>
|
<span>· Updated {new Date(m.updatedAt).toLocaleString()}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{editorLabel && m.lastEditedBy !== m.ownerUserId ? (
|
||||||
|
<span className="text-fg-subtle">
|
||||||
|
· Last edited by {editorLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<form action={updateMemoryAction} className="space-y-4">
|
<form action={updateMemoryAction} className="space-y-4">
|
||||||
<input type="hidden" name="id" value={m.id} />
|
<input type="hidden" name="id" value={m.id} />
|
||||||
|
<input type="hidden" name="version" value={m.version} />
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="scope">Scope</Label>
|
<Label htmlFor="scope">Scope</Label>
|
||||||
<select
|
<select
|
||||||
@@ -168,7 +233,7 @@ export default async function MemoryDetailPage({
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing && canWrite ? (
|
||||||
<form action={deleteMemoryAction} className="flex justify-end">
|
<form action={deleteMemoryAction} className="flex justify-end">
|
||||||
<input type="hidden" name="id" value={m.id} />
|
<input type="hidden" name="id" value={m.id} />
|
||||||
<Button type="submit" variant="danger" size="sm">
|
<Button type="submit" variant="danger" size="sm">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import Link from "next/link";
|
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 { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db/client";
|
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 { searchMemories } from "@/lib/memories";
|
||||||
|
import { getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||||
import { Card, CardBody } from "@/app/_components/ui/card";
|
import { Card, CardBody } from "@/app/_components/ui/card";
|
||||||
import { Badge } from "@/app/_components/ui/badge";
|
import { Badge } from "@/app/_components/ui/badge";
|
||||||
@@ -18,15 +19,16 @@ type Scope = "project" | "user";
|
|||||||
interface MemoryRow {
|
interface MemoryRow {
|
||||||
id: string;
|
id: string;
|
||||||
scope: "project" | "user";
|
scope: "project" | "user";
|
||||||
|
projectId: string | null;
|
||||||
projectKey: string | null;
|
projectKey: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
rank?: { rrfScore: number; vectorRank: number | null; ftsRank: number | null; tagRank: number | null };
|
rank?: { rrfScore: number; vectorRank: number | null; ftsRank: number | null; tagRank: number | null };
|
||||||
|
shared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMemoriesByIds(
|
async function fetchMemoriesByIds(
|
||||||
userId: string,
|
|
||||||
ids: string[],
|
ids: string[],
|
||||||
): Promise<Map<string, MemoryRow>> {
|
): Promise<Map<string, MemoryRow>> {
|
||||||
if (ids.length === 0) return new Map();
|
if (ids.length === 0) return new Map();
|
||||||
@@ -37,22 +39,39 @@ async function fetchMemoriesByIds(
|
|||||||
content: memories.content,
|
content: memories.content,
|
||||||
tags: memories.tags,
|
tags: memories.tags,
|
||||||
createdAt: memories.createdAt,
|
createdAt: memories.createdAt,
|
||||||
|
projectId: memories.projectId,
|
||||||
projectKey: projects.key,
|
projectKey: projects.key,
|
||||||
})
|
})
|
||||||
.from(memories)
|
.from(memories)
|
||||||
.leftJoin(projects, eq(memories.projectId, projects.id))
|
.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]));
|
return new Map(rows.map((r) => [r.id, r as MemoryRow]));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listRecent(userId: string, scope?: Scope, project?: string): Promise<MemoryRow[]> {
|
async function listRecent(
|
||||||
const filters = [eq(memories.userId, userId), isNull(memories.deletedAt)];
|
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 (scope) filters.push(eq(memories.scope, scope));
|
||||||
if (project) {
|
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(
|
filters.push(
|
||||||
sql`${memories.projectId} = (
|
sql`${memories.projectId} IN (
|
||||||
SELECT id FROM ${projects}
|
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,
|
content: memories.content,
|
||||||
tags: memories.tags,
|
tags: memories.tags,
|
||||||
createdAt: memories.createdAt,
|
createdAt: memories.createdAt,
|
||||||
|
projectId: memories.projectId,
|
||||||
projectKey: projects.key,
|
projectKey: projects.key,
|
||||||
})
|
})
|
||||||
.from(memories)
|
.from(memories)
|
||||||
@@ -73,6 +93,20 @@ async function listRecent(userId: string, scope?: Scope, project?: string): Prom
|
|||||||
return rows;
|
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({
|
export default async function MemoriesPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -80,6 +114,7 @@ export default async function MemoriesPage({
|
|||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const userId = session!.user.id;
|
const userId = session!.user.id;
|
||||||
|
const groupNames = await getUserGroupNames(userId);
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const q = params.q?.trim() || undefined;
|
const q = params.q?.trim() || undefined;
|
||||||
const scope = params.scope === "user" || params.scope === "project" ? params.scope : 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;
|
let debug: { vec: number; fts: number; tag: number } | null = null;
|
||||||
|
|
||||||
if (q) {
|
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 ids = result.hits.map((h) => h.id);
|
||||||
const byId = await fetchMemoriesByIds(userId, ids);
|
const byId = await fetchMemoriesByIds(ids);
|
||||||
rows = result.hits.flatMap((h) => {
|
rows = result.hits.flatMap((h) => {
|
||||||
const r = byId.get(h.id);
|
const r = byId.get(h.id);
|
||||||
return r ? [{ ...r, rank: h.rank }] : [];
|
return r ? [{ ...r, rank: h.rank }] : [];
|
||||||
});
|
});
|
||||||
debug = result.debug;
|
debug = result.debug;
|
||||||
} else {
|
} 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 (
|
return (
|
||||||
<Container className="pt-6">
|
<Container className="pt-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -168,6 +220,11 @@ export default async function MemoriesPage({
|
|||||||
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
|
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
|
||||||
{m.scope}
|
{m.scope}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{m.shared ? (
|
||||||
|
<Badge tone="accent" title="Shared with one or more groups">
|
||||||
|
Shared
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
{m.projectKey ? (
|
{m.projectKey ? (
|
||||||
<span className="font-mono">· {m.projectKey}</span>
|
<span className="font-mono">· {m.projectKey}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,17 +1,48 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
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 { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db/client";
|
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 { 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 { Badge } from "@/app/_components/ui/badge";
|
||||||
import { Button } from "@/app/_components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
import { Input, Label } from "@/app/_components/ui/input";
|
||||||
import { EmptyState } from "@/app/_components/ui/empty-state";
|
import { EmptyState } from "@/app/_components/ui/empty-state";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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({
|
export default async function ProjectDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -21,15 +52,66 @@ export default async function ProjectDetailPage({
|
|||||||
const userId = session!.user.id;
|
const userId = session!.user.id;
|
||||||
const { key: rawKey } = await params;
|
const { key: rawKey } = await params;
|
||||||
const key = decodeURIComponent(rawKey);
|
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()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(eq(projects.userId, userId), eq(projects.key, key)))
|
.where(and(eq(projects.userId, userId), eq(projects.key, key)))
|
||||||
.limit(1);
|
.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
|
const mem = await db
|
||||||
.select({
|
.select({
|
||||||
id: memories.id,
|
id: memories.id,
|
||||||
@@ -39,53 +121,211 @@ export default async function ProjectDetailPage({
|
|||||||
createdAt: memories.createdAt,
|
createdAt: memories.createdAt,
|
||||||
})
|
})
|
||||||
.from(memories)
|
.from(memories)
|
||||||
.where(
|
.where(and(eq(memories.projectId, project.id), isNull(memories.deletedAt)))
|
||||||
and(
|
|
||||||
eq(memories.userId, userId),
|
|
||||||
eq(memories.projectId, project.id),
|
|
||||||
isNull(memories.deletedAt),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(memories.createdAt))
|
.orderBy(desc(memories.createdAt))
|
||||||
.limit(100);
|
.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 (
|
return (
|
||||||
<Container className="pt-6">
|
<Container className="pt-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={project.displayName ?? project.key}
|
title={project.displayName ?? project.key}
|
||||||
description={
|
description={
|
||||||
<>
|
<span className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-mono">{project.key}</span>
|
<span className="font-mono">{project.key}</span>
|
||||||
{" · "}
|
<span>·</span>
|
||||||
|
<span>
|
||||||
{mem.length} memor{mem.length === 1 ? "y" : "ies"}
|
{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={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Link href="/projects" className="no-underline">
|
<Link href="/projects" className="no-underline">
|
||||||
<Button type="button" variant="secondary">All projects</Button>
|
<Button type="button" variant="secondary">All projects</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{canWrite ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||||
className="no-underline"
|
className="no-underline"
|
||||||
>
|
>
|
||||||
<Button>New in this project</Button>
|
<Button>New in this project</Button>
|
||||||
</Link>
|
</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 ? (
|
{mem.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No memories in this project yet"
|
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={
|
action={
|
||||||
|
canWrite ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||||
className="no-underline"
|
className="no-underline"
|
||||||
>
|
>
|
||||||
<Button>Create the first one</Button>
|
<Button>Create the first one</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -99,6 +339,14 @@ export default async function ProjectDetailPage({
|
|||||||
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
|
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
|
||||||
{m.scope}
|
{m.scope}
|
||||||
</Badge>
|
</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>
|
<span>{new Date(m.createdAt).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-fg line-clamp-3">{m.content}</p>
|
<p className="text-sm text-fg line-clamp-3">{m.content}</p>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
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 { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db/client";
|
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 { updateSnippetAction, deleteSnippetAction } from "@/lib/snippet-actions";
|
||||||
import { getSnippet, type SnippetWithProjectKey } from "@/lib/snippets";
|
import { getSnippet, type SnippetWithProjectKey } from "@/lib/snippets";
|
||||||
|
import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access";
|
||||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||||
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
||||||
import { Input, Textarea, Label } from "@/app/_components/ui/input";
|
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
|
* 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
|
* 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.
|
* 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
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
scope: snippets.scope,
|
scope: snippets.scope,
|
||||||
@@ -32,7 +50,7 @@ async function findAllMatches(userId: string, name: string): Promise<SiblingHit[
|
|||||||
})
|
})
|
||||||
.from(snippets)
|
.from(snippets)
|
||||||
.leftJoin(projects, eq(snippets.projectId, projects.id))
|
.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[];
|
return rows as SiblingHit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,15 +63,16 @@ export default async function SnippetDetailPage({
|
|||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const userId = session!.user.id;
|
const userId = session!.user.id;
|
||||||
|
const groupNames = await getUserGroupNames(userId);
|
||||||
const { name: rawName } = await params;
|
const { name: rawName } = await params;
|
||||||
const name = decodeURIComponent(rawName);
|
const name = decodeURIComponent(rawName);
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const scope: "project" | "user" | undefined =
|
const scope: "project" | "user" | undefined =
|
||||||
sp.scope === "user" || sp.scope === "project" ? sp.scope : undefined;
|
sp.scope === "user" || sp.scope === "project" ? sp.scope : undefined;
|
||||||
const project = sp.project?.trim() || 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 (siblings.length === 0) notFound();
|
||||||
|
|
||||||
// If multiple matches and the user hasn't disambiguated, show a picker.
|
// If multiple matches and the user hasn't disambiguated, show a picker.
|
||||||
@@ -103,10 +122,36 @@ export default async function SnippetDetailPage({
|
|||||||
name,
|
name,
|
||||||
scope,
|
scope,
|
||||||
projectKey: project,
|
projectKey: project,
|
||||||
|
groupNames,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!snippet) notFound();
|
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 (
|
return (
|
||||||
<Container className="pt-6 max-w-3xl">
|
<Container className="pt-6 max-w-3xl">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -124,7 +169,7 @@ export default async function SnippetDetailPage({
|
|||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{!isEditing ? (
|
{!isEditing && canWrite ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/snippets/${encodeURIComponent(snippet.name)}?${new URLSearchParams({
|
href={`/snippets/${encodeURIComponent(snippet.name)}?${new URLSearchParams({
|
||||||
scope: snippet.scope,
|
scope: snippet.scope,
|
||||||
@@ -150,6 +195,9 @@ export default async function SnippetDetailPage({
|
|||||||
{snippet.updatedAt.getTime() !== snippet.createdAt.getTime() ? (
|
{snippet.updatedAt.getTime() !== snippet.createdAt.getTime() ? (
|
||||||
<span>· Updated {new Date(snippet.updatedAt).toLocaleString()}</span>
|
<span>· Updated {new Date(snippet.updatedAt).toLocaleString()}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{editorLabel && snippet.lastEditedBy !== snippet.userId ? (
|
||||||
|
<span className="text-fg-subtle">· Last edited by {editorLabel}</span>
|
||||||
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
@@ -157,6 +205,7 @@ export default async function SnippetDetailPage({
|
|||||||
<form action={updateSnippetAction} className="space-y-4">
|
<form action={updateSnippetAction} className="space-y-4">
|
||||||
<input type="hidden" name="name" value={snippet.name} />
|
<input type="hidden" name="name" value={snippet.name} />
|
||||||
<input type="hidden" name="scope" value={snippet.scope} />
|
<input type="hidden" name="scope" value={snippet.scope} />
|
||||||
|
<input type="hidden" name="version" value={snippet.version} />
|
||||||
{snippet.scope === "project" && snippet.projectKey ? (
|
{snippet.scope === "project" && snippet.projectKey ? (
|
||||||
<input type="hidden" name="project" value={snippet.projectKey} />
|
<input type="hidden" name="project" value={snippet.projectKey} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -234,7 +283,7 @@ export default async function SnippetDetailPage({
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing && canWrite ? (
|
||||||
<form action={deleteSnippetAction} className="flex justify-end">
|
<form action={deleteSnippetAction} className="flex justify-end">
|
||||||
<input type="hidden" name="name" value={snippet.name} />
|
<input type="hidden" name="name" value={snippet.name} />
|
||||||
<input type="hidden" name="scope" value={snippet.scope} />
|
<input type="hidden" name="scope" value={snippet.scope} />
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
customType,
|
customType,
|
||||||
vector,
|
vector,
|
||||||
varchar,
|
varchar,
|
||||||
|
integer,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { sql } from "drizzle-orm";
|
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 memoryScope = pgEnum("memory_scope", ["project", "user"]);
|
||||||
export const memoryVisibility = pgEnum("memory_visibility", ["private", "shared", "team"]);
|
export const memoryVisibility = pgEnum("memory_visibility", ["private", "shared", "team"]);
|
||||||
export const auditActor = pgEnum("audit_actor", ["mcp", "web", "system"]);
|
export const auditActor = pgEnum("audit_actor", ["mcp", "web", "system"]);
|
||||||
// Reserved here so 0003 (this file's matching migration) owns it. Used
|
// Created by 0003_groups.sql; declared here so the TS layer (notably
|
||||||
// by Agent B's upcoming `project_shares` table to express RO vs RW
|
// `project_shares`) can reference it as a typed pgEnum.
|
||||||
// grants per shared group.
|
|
||||||
export const memoryAccess = pgEnum("memory_access", ["ro", "rw"]);
|
export const memoryAccess = pgEnum("memory_access", ["ro", "rw"]);
|
||||||
|
|
||||||
// ---------- tables ----------
|
// ---------- tables ----------
|
||||||
@@ -49,7 +49,7 @@ export const users = pgTable(
|
|||||||
"users",
|
"users",
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
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(),
|
oidcSub: text("oidc_sub").notNull(),
|
||||||
// OIDC `iss` so we can disambiguate if we ever federate.
|
// OIDC `iss` so we can disambiguate if we ever federate.
|
||||||
oidcIss: text("oidc_iss").notNull(),
|
oidcIss: text("oidc_iss").notNull(),
|
||||||
@@ -99,6 +99,14 @@ export const memories = pgTable(
|
|||||||
embedding: vector("embedding", { dimensions: 384 }),
|
embedding: vector("embedding", { dimensions: 384 }),
|
||||||
// Generated column — see migration SQL for definition.
|
// Generated column — see migration SQL for definition.
|
||||||
contentTsv: tsvector("content_tsv"),
|
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(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
@@ -126,6 +134,10 @@ export const snippets = pgTable(
|
|||||||
body: text("body").notNull(),
|
body: text("body").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
tags: textArray("tags").notNull().default([]),
|
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(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
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(
|
export const groups = pgTable(
|
||||||
"groups",
|
"groups",
|
||||||
{
|
{
|
||||||
@@ -164,9 +184,8 @@ export const groups = pgTable(
|
|||||||
// OIDC issuer this group originates from — pairs with `name` so two
|
// OIDC issuer this group originates from — pairs with `name` so two
|
||||||
// IdPs can both have a "platform" group without collision.
|
// IdPs can both have a "platform" group without collision.
|
||||||
oidcIss: text("oidc_iss").notNull(),
|
oidcIss: text("oidc_iss").notNull(),
|
||||||
|
// Group `name` as it appears in the JWT (Authentik / EntraID groups claim).
|
||||||
name: text("name").notNull(),
|
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"),
|
displayName: text("display_name"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
@@ -191,6 +210,34 @@ export const userGroups = pgTable(
|
|||||||
(t) => ({
|
(t) => ({
|
||||||
pk: primaryKey({ columns: [t.userId, t.groupId] }),
|
pk: primaryKey({ columns: [t.userId, t.groupId] }),
|
||||||
userIdx: index("user_groups_user_idx").on(t.userId),
|
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 NewGroup = typeof groups.$inferInsert;
|
||||||
export type UserGroup = typeof userGroups.$inferSelect;
|
export type UserGroup = typeof userGroups.$inferSelect;
|
||||||
export type NewUserGroup = typeof userGroups.$inferInsert;
|
export type NewUserGroup = typeof userGroups.$inferInsert;
|
||||||
|
export type ProjectShare = typeof projectShares.$inferSelect;
|
||||||
|
export type NewProjectShare = typeof projectShares.$inferInsert;
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import type { AuthenticatedClaims } from "@/lib/auth/jwt";
|
|||||||
*
|
*
|
||||||
* Resolves (or creates) the internal `users` row from the OIDC claims so
|
* Resolves (or creates) the internal `users` row from the OIDC claims so
|
||||||
* tools work with stable UUID foreign keys rather than raw `sub` strings.
|
* 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 {
|
export interface UserContext {
|
||||||
/** Internal users.id UUID. */
|
/** Internal users.id UUID. */
|
||||||
@@ -85,8 +91,7 @@ export async function userContextFromClaims(
|
|||||||
// emit it). CLI tokens never do — they go through verifyCliToken which
|
// 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
|
// doesn't set claims.groups. In that case fall back to the DB snapshot
|
||||||
// from the user's last interactive sign-in.
|
// from the user's last interactive sign-in.
|
||||||
const groupNames =
|
const groupNames = claims.groups ?? (await loadUserGroups(userId));
|
||||||
claims.groups ?? (await loadUserGroups(userId));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
+339
-72
@@ -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 { db } from "@/lib/db/client";
|
||||||
import { memories, projects, auditLog } from "@/lib/db/schema";
|
import {
|
||||||
|
memories,
|
||||||
|
projects,
|
||||||
|
projectShares,
|
||||||
|
groups,
|
||||||
|
auditLog,
|
||||||
|
} from "@/lib/db/schema";
|
||||||
import {
|
import {
|
||||||
MemoryIdInput,
|
MemoryIdInput,
|
||||||
MemoryListInput,
|
MemoryListInput,
|
||||||
@@ -21,6 +27,12 @@ import {
|
|||||||
listSnippets,
|
listSnippets,
|
||||||
softDeleteSnippet,
|
softDeleteSnippet,
|
||||||
} from "@/lib/snippets";
|
} from "@/lib/snippets";
|
||||||
|
import {
|
||||||
|
CONCURRENT_EDIT_ERROR,
|
||||||
|
canWriteProject,
|
||||||
|
getProjectAccess,
|
||||||
|
readableProjectIds,
|
||||||
|
} from "@/lib/access";
|
||||||
import type { UserContext } from "./context";
|
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(
|
async function resolveProjectId(
|
||||||
ctx: UserContext,
|
ctx: UserContext,
|
||||||
projectKey: string | undefined,
|
projectKey: string | undefined,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (!projectKey) return null;
|
if (!projectKey) return null;
|
||||||
const row = await db
|
const owned = await db
|
||||||
.select({ id: projects.id })
|
.select({ id: projects.id })
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(eq(projects.userId, ctx.userId), eq(projects.key, projectKey)))
|
.where(and(eq(projects.userId, ctx.userId), eq(projects.key, projectKey)))
|
||||||
.limit(1);
|
.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 = {
|
const projectIdentify: ToolDef = {
|
||||||
name: "project.identify",
|
name: "project.identify",
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -134,20 +175,121 @@ const projectIdentify: ToolDef = {
|
|||||||
const parsed = ProjectIdentifyInput.safeParse(args);
|
const parsed = ProjectIdentifyInput.safeParse(args);
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
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)
|
.insert(projects)
|
||||||
.values({
|
.values({
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
key: parsed.data.key,
|
key: parsed.data.key,
|
||||||
displayName: parsed.data.display_name ?? null,
|
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({
|
.returning({
|
||||||
id: projects.id,
|
id: projects.id,
|
||||||
key: projects.key,
|
key: projects.key,
|
||||||
@@ -155,22 +297,34 @@ const projectIdentify: ToolDef = {
|
|||||||
createdAt: projects.createdAt,
|
createdAt: projects.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
const p = row[0]!;
|
const p = created[0]!;
|
||||||
return ok(p, `project ${p.key} (${p.id})`);
|
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 = {
|
const memoryWrite: ToolDef = {
|
||||||
name: "memory.write",
|
name: "memory.write",
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
content: { type: "string", description: "Memory content (1–64,000 chars)." },
|
content: { type: "string", description: "Memory content (1–64,000 chars)." },
|
||||||
project: {
|
project: {
|
||||||
type: "string",
|
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: {
|
scope: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -191,11 +345,20 @@ const memoryWrite: ToolDef = {
|
|||||||
|
|
||||||
const scope = parsed.data.scope;
|
const scope = parsed.data.scope;
|
||||||
let projectId: string | null = null;
|
let projectId: string | null = null;
|
||||||
|
let projectKey: string | undefined = undefined;
|
||||||
if (scope === "project") {
|
if (scope === "project") {
|
||||||
if (!parsed.data.project) return err("scope=project requires `project` key");
|
projectKey = projectKeyOrDefault(ctx, parsed.data.project);
|
||||||
projectId = await resolveProjectId(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) {
|
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,
|
content: parsed.data.content,
|
||||||
tags: parsed.data.tags ?? [],
|
tags: parsed.data.tags ?? [],
|
||||||
embedding,
|
embedding,
|
||||||
|
lastEditedBy: ctx.userId,
|
||||||
})
|
})
|
||||||
.returning({ id: memories.id, createdAt: memories.createdAt });
|
.returning({ id: memories.id, createdAt: memories.createdAt });
|
||||||
|
|
||||||
@@ -224,7 +388,7 @@ const memoryWrite: ToolDef = {
|
|||||||
action: "memory.write",
|
action: "memory.write",
|
||||||
entityType: "memory",
|
entityType: "memory",
|
||||||
entityId: m.id,
|
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}`);
|
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));
|
const parsed = MemoryListInput.safeParse(withDefaultProject(args, ctx));
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
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.scope) where.push(eq(memories.scope, parsed.data.scope));
|
||||||
|
|
||||||
if (parsed.data.project) {
|
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
|
||||||
const projectId = await resolveProjectId(ctx, parsed.data.project);
|
if (requestedKey) {
|
||||||
|
const projectId = await resolveProjectId(ctx, requestedKey);
|
||||||
if (!projectId) return ok({ items: [], next_cursor: null }, "0 results");
|
if (!projectId) return ok({ items: [], next_cursor: null }, "0 results");
|
||||||
where.push(eq(memories.projectId, projectId));
|
where.push(eq(memories.projectId, projectId));
|
||||||
}
|
}
|
||||||
@@ -273,6 +445,8 @@ const memoryList: ToolDef = {
|
|||||||
projectId: memories.projectId,
|
projectId: memories.projectId,
|
||||||
content: memories.content,
|
content: memories.content,
|
||||||
tags: memories.tags,
|
tags: memories.tags,
|
||||||
|
version: memories.version,
|
||||||
|
lastEditedBy: memories.lastEditedBy,
|
||||||
createdAt: memories.createdAt,
|
createdAt: memories.createdAt,
|
||||||
updatedAt: memories.updatedAt,
|
updatedAt: memories.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -301,17 +475,21 @@ const memoryGet: ToolDef = {
|
|||||||
const row = await db
|
const row = await db
|
||||||
.select()
|
.select()
|
||||||
.from(memories)
|
.from(memories)
|
||||||
.where(
|
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
|
||||||
and(
|
|
||||||
eq(memories.id, parsed.data.id),
|
|
||||||
eq(memories.userId, ctx.userId),
|
|
||||||
isNull(memories.deletedAt),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!row[0]) return err("not found");
|
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);
|
const parsed = MemoryIdInput.safeParse(args);
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
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
|
const updated = await db
|
||||||
.update(memories)
|
.update(memories)
|
||||||
.set({ deletedAt: new Date() })
|
.set({ deletedAt: new Date(), lastEditedBy: ctx.userId })
|
||||||
.where(
|
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
|
||||||
and(
|
|
||||||
eq(memories.id, parsed.data.id),
|
|
||||||
eq(memories.userId, ctx.userId),
|
|
||||||
isNull(memories.deletedAt),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning({ id: memories.id });
|
.returning({ id: memories.id });
|
||||||
|
|
||||||
if (!updated[0]) return err("not found");
|
if (!updated[0]) return err("not found");
|
||||||
@@ -357,7 +553,7 @@ const memoryDelete: ToolDef = {
|
|||||||
const memoryUpdate: ToolDef = {
|
const memoryUpdate: ToolDef = {
|
||||||
name: "memory.update",
|
name: "memory.update",
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -375,6 +571,12 @@ const memoryUpdate: ToolDef = {
|
|||||||
description:
|
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.",
|
"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"],
|
required: ["id"],
|
||||||
},
|
},
|
||||||
@@ -389,21 +591,29 @@ const memoryUpdate: ToolDef = {
|
|||||||
scope: memories.scope,
|
scope: memories.scope,
|
||||||
projectId: memories.projectId,
|
projectId: memories.projectId,
|
||||||
projectKey: projects.key,
|
projectKey: projects.key,
|
||||||
|
version: memories.version,
|
||||||
|
userId: memories.userId,
|
||||||
})
|
})
|
||||||
.from(memories)
|
.from(memories)
|
||||||
.leftJoin(projects, eq(memories.projectId, projects.id))
|
.leftJoin(projects, eq(memories.projectId, projects.id))
|
||||||
.where(
|
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
|
||||||
and(
|
|
||||||
eq(memories.id, parsed.data.id),
|
|
||||||
eq(memories.userId, ctx.userId),
|
|
||||||
isNull(memories.deletedAt),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const existing = existingRows[0];
|
const existing = existingRows[0];
|
||||||
if (!existing) return err("not found");
|
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.tags !== undefined) update.tags = parsed.data.tags;
|
||||||
if (parsed.data.content !== undefined && parsed.data.content !== existing.content) {
|
if (parsed.data.content !== undefined && parsed.data.content !== existing.content) {
|
||||||
update.content = parsed.data.content;
|
update.content = parsed.data.content;
|
||||||
@@ -426,12 +636,17 @@ const memoryUpdate: ToolDef = {
|
|||||||
newProjectKey = null;
|
newProjectKey = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// scope === 'project' — schema refine guarantees `project` is set
|
// scope === 'project' — schema refine guarantees `project` is set.
|
||||||
const projectKey = parsed.data.project!;
|
const projectKey = parsed.data.project!;
|
||||||
const projectId = await resolveProjectId(ctx, projectKey);
|
const projectId = await resolveProjectId(ctx, projectKey);
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
return err(`unknown project '${projectKey}'; call project.identify first`);
|
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") {
|
if (existing.scope !== "project") {
|
||||||
update.scope = "project";
|
update.scope = "project";
|
||||||
scopeChanged = true;
|
scopeChanged = true;
|
||||||
@@ -444,13 +659,27 @@ const memoryUpdate: ToolDef = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expectedVersion = parsed.data.version ?? existing.version;
|
||||||
const updated = await db
|
const updated = await db
|
||||||
.update(memories)
|
.update(memories)
|
||||||
.set(update)
|
.set(update)
|
||||||
.where(eq(memories.id, parsed.data.id))
|
.where(
|
||||||
.returning({ id: memories.id, updatedAt: memories.updatedAt });
|
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 };
|
const auditPayload: Record<string, unknown> = { fields: auditFields };
|
||||||
if (scopeChanged || projectChanged) {
|
if (scopeChanged || projectChanged) {
|
||||||
auditPayload.scope = {
|
auditPayload.scope = {
|
||||||
@@ -496,17 +725,16 @@ const memorySearch: ToolDef = {
|
|||||||
if (!parsed.success) return err(parsed.error.message);
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
const { query, scope, tags, limit } = parsed.data;
|
const { query, scope, tags, limit } = parsed.data;
|
||||||
const projectId = parsed.data.project
|
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
|
||||||
? await resolveProjectId(ctx, parsed.data.project)
|
const projectId = requestedKey ? await resolveProjectId(ctx, requestedKey) : null;
|
||||||
: null;
|
if (requestedKey && !projectId) {
|
||||||
if (parsed.data.project && !projectId) {
|
|
||||||
return ok({ items: [], debug: { vec: 0, fts: 0, tag: 0 } }, "0 results (unknown project)");
|
return ok({ items: [], debug: { vec: 0, fts: 0, tag: 0 } }, "0 results (unknown project)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await searchMemories(
|
const result = await searchMemories(
|
||||||
ctx.userId,
|
ctx.userId,
|
||||||
query,
|
query,
|
||||||
{ scope, projectKey: parsed.data.project, tags },
|
{ scope, projectKey: requestedKey, tags, groupNames: ctx.groups },
|
||||||
limit,
|
limit,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -522,6 +750,8 @@ const memorySearch: ToolDef = {
|
|||||||
projectId: memories.projectId,
|
projectId: memories.projectId,
|
||||||
content: memories.content,
|
content: memories.content,
|
||||||
tags: memories.tags,
|
tags: memories.tags,
|
||||||
|
version: memories.version,
|
||||||
|
lastEditedBy: memories.lastEditedBy,
|
||||||
createdAt: memories.createdAt,
|
createdAt: memories.createdAt,
|
||||||
updatedAt: memories.updatedAt,
|
updatedAt: memories.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -549,7 +779,7 @@ const memorySearch: ToolDef = {
|
|||||||
const snippetPut: ToolDef = {
|
const snippetPut: ToolDef = {
|
||||||
name: "snippet.put",
|
name: "snippet.put",
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -570,17 +800,24 @@ const snippetPut: ToolDef = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
enum: ["project", "user"],
|
enum: ["project", "user"],
|
||||||
description:
|
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: {
|
project: {
|
||||||
type: "string",
|
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: {
|
tags: {
|
||||||
type: "array",
|
type: "array",
|
||||||
items: { type: "string" },
|
items: { type: "string" },
|
||||||
description: "Optional tags for grouping in the Web UI.",
|
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"],
|
required: ["name", "body"],
|
||||||
},
|
},
|
||||||
@@ -590,22 +827,34 @@ const snippetPut: ToolDef = {
|
|||||||
const parsed = SnippetPutInput.safeParse(withDefaultProject(args, ctx, "user"));
|
const parsed = SnippetPutInput.safeParse(withDefaultProject(args, ctx, "user"));
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
if (parsed.data.scope === "project") {
|
// Apply X-Project-Key default for scope=project.
|
||||||
const exists = await resolveProjectId(ctx, parsed.data.project!);
|
const projectKey =
|
||||||
if (!exists) {
|
parsed.data.scope === "project"
|
||||||
return err(
|
? projectKeyOrDefault(ctx, parsed.data.project)
|
||||||
`unknown project '${parsed.data.project}'; call project.identify first`,
|
: undefined;
|
||||||
);
|
if (parsed.data.scope === "project" && !projectKey) {
|
||||||
}
|
return err("scope=project requires `project` (or X-Project-Key header)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const { snippet, inserted } = await putSnippet(ctx.userId, {
|
const { snippet, inserted } = await putSnippet(ctx.userId, {
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
body: parsed.data.body,
|
body: parsed.data.body,
|
||||||
description: parsed.data.description,
|
description: parsed.data.description,
|
||||||
tags: parsed.data.tags,
|
tags: parsed.data.tags,
|
||||||
scope: parsed.data.scope,
|
scope: parsed.data.scope,
|
||||||
projectKey: parsed.data.project,
|
projectKey,
|
||||||
|
groupNames: ctx.groups,
|
||||||
|
version: parsed.data.version,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(auditLog).values({
|
await db.insert(auditLog).values({
|
||||||
@@ -628,10 +877,14 @@ const snippetPut: ToolDef = {
|
|||||||
name: snippet.name,
|
name: snippet.name,
|
||||||
scope: snippet.scope,
|
scope: snippet.scope,
|
||||||
project: snippet.projectKey,
|
project: snippet.projectKey,
|
||||||
|
version: snippet.version,
|
||||||
inserted,
|
inserted,
|
||||||
},
|
},
|
||||||
`${inserted ? "wrote" : "updated"} snippet '${snippet.name}' (${snippet.scope})`,
|
`${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));
|
const parsed = SnippetGetInput.safeParse(withDefaultProject(args, ctx));
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
|
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
|
||||||
const snippet = await getSnippet(ctx.userId, {
|
const snippet = await getSnippet(ctx.userId, {
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
scope: parsed.data.scope,
|
scope: parsed.data.scope,
|
||||||
projectKey: parsed.data.project,
|
projectKey: requestedKey,
|
||||||
|
groupNames: ctx.groups,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!snippet) return err(`snippet '${parsed.data.name}' not found`);
|
if (!snippet) return err(`snippet '${parsed.data.name}' not found`);
|
||||||
@@ -678,6 +933,8 @@ const snippetGet: ToolDef = {
|
|||||||
scope: snippet.scope,
|
scope: snippet.scope,
|
||||||
project: snippet.projectKey,
|
project: snippet.projectKey,
|
||||||
tags: snippet.tags,
|
tags: snippet.tags,
|
||||||
|
version: snippet.version,
|
||||||
|
lastEditedBy: snippet.lastEditedBy,
|
||||||
createdAt: snippet.createdAt,
|
createdAt: snippet.createdAt,
|
||||||
updatedAt: snippet.updatedAt,
|
updatedAt: snippet.updatedAt,
|
||||||
},
|
},
|
||||||
@@ -710,11 +967,13 @@ const snippetList: ToolDef = {
|
|||||||
const parsed = SnippetListInput.safeParse(withDefaultProject(args, ctx));
|
const parsed = SnippetListInput.safeParse(withDefaultProject(args, ctx));
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
|
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
|
||||||
const rows = await listSnippets(ctx.userId, {
|
const rows = await listSnippets(ctx.userId, {
|
||||||
scope: parsed.data.scope,
|
scope: parsed.data.scope,
|
||||||
projectKey: parsed.data.project,
|
projectKey: requestedKey,
|
||||||
tags: parsed.data.tags,
|
tags: parsed.data.tags,
|
||||||
limit: parsed.data.limit,
|
limit: parsed.data.limit,
|
||||||
|
groupNames: ctx.groups,
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = rows.map((r) => ({
|
const items = rows.map((r) => ({
|
||||||
@@ -724,6 +983,8 @@ const snippetList: ToolDef = {
|
|||||||
scope: r.scope,
|
scope: r.scope,
|
||||||
project: r.projectKey,
|
project: r.projectKey,
|
||||||
tags: r.tags,
|
tags: r.tags,
|
||||||
|
version: r.version,
|
||||||
|
lastEditedBy: r.lastEditedBy,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
updatedAt: r.updatedAt,
|
updatedAt: r.updatedAt,
|
||||||
}));
|
}));
|
||||||
@@ -749,10 +1010,13 @@ const snippetDelete: ToolDef = {
|
|||||||
const parsed = SnippetDeleteInput.safeParse(withDefaultProject(args, ctx));
|
const parsed = SnippetDeleteInput.safeParse(withDefaultProject(args, ctx));
|
||||||
if (!parsed.success) return err(parsed.error.message);
|
if (!parsed.success) return err(parsed.error.message);
|
||||||
|
|
||||||
|
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
|
||||||
|
try {
|
||||||
const deleted = await softDeleteSnippet(ctx.userId, {
|
const deleted = await softDeleteSnippet(ctx.userId, {
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
scope: parsed.data.scope,
|
scope: parsed.data.scope,
|
||||||
projectKey: parsed.data.project,
|
projectKey: requestedKey,
|
||||||
|
groupNames: ctx.groups,
|
||||||
});
|
});
|
||||||
if (!deleted) return err(`snippet '${parsed.data.name}' not found`);
|
if (!deleted) return err(`snippet '${parsed.data.name}' not found`);
|
||||||
|
|
||||||
@@ -773,6 +1037,9 @@ const snippetDelete: ToolDef = {
|
|||||||
{ id: deleted.id, name: parsed.data.name, deleted: true },
|
{ id: deleted.id, name: parsed.data.name, deleted: true },
|
||||||
`deleted snippet '${parsed.data.name}' (${deleted.scope})`,
|
`deleted snippet '${parsed.data.name}' (${deleted.scope})`,
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return err(e instanceof Error ? e.message : "snippet.delete failed");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+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 { db, pg } from "@/lib/db/client";
|
||||||
import { projects } from "@/lib/db/schema";
|
import { projects } from "@/lib/db/schema";
|
||||||
import { embedText } from "@/lib/embedder";
|
import { embedText } from "@/lib/embedder";
|
||||||
|
import { readableProjectIds } from "@/lib/access";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared search helper. Used by:
|
* Shared search helper. Used by:
|
||||||
@@ -11,12 +12,23 @@ import { embedText } from "@/lib/embedder";
|
|||||||
* Performs three candidate fetches in parallel — pgvector cosine, FTS
|
* Performs three candidate fetches in parallel — pgvector cosine, FTS
|
||||||
* ts_rank_cd, tag-set overlap — then fuses with Reciprocal Rank Fusion
|
* ts_rank_cd, tag-set overlap — then fuses with Reciprocal Rank Fusion
|
||||||
* (k=60). Returns top-N with per-source rank info attached.
|
* (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 {
|
export interface SearchFilters {
|
||||||
scope?: "project" | "user";
|
scope?: "project" | "user";
|
||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
tags?: 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 {
|
export interface SearchHit {
|
||||||
@@ -41,17 +53,38 @@ function toVectorLiteral(v: number[]): string {
|
|||||||
return `[${v.join(",")}]`;
|
return `[${v.join(",")}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveProjectId(
|
async function resolveProjectIdForKey(
|
||||||
userId: string,
|
userId: string,
|
||||||
projectKey?: string,
|
groupNames: string[],
|
||||||
|
projectKey: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (!projectKey) return null;
|
// First check owned. Owned wins on key collision (matches
|
||||||
const row = await db
|
// project.identify's priority).
|
||||||
|
const owned = await db
|
||||||
.select({ id: projects.id })
|
.select({ id: projects.id })
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(eq(projects.userId, userId), eq(projects.key, projectKey)))
|
.where(and(eq(projects.userId, userId), eq(projects.key, projectKey)))
|
||||||
.limit(1);
|
.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(
|
export async function searchMemories(
|
||||||
@@ -60,8 +93,10 @@ export async function searchMemories(
|
|||||||
filters: SearchFilters = {},
|
filters: SearchFilters = {},
|
||||||
limit = 20,
|
limit = 20,
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
const { scope, projectKey, tags } = filters;
|
const { scope, projectKey, tags, groupNames = [] } = filters;
|
||||||
const projectId = projectKey ? await resolveProjectId(userId, projectKey) : null;
|
const projectId = projectKey
|
||||||
|
? await resolveProjectIdForKey(userId, groupNames, projectKey)
|
||||||
|
: null;
|
||||||
if (projectKey && !projectId) {
|
if (projectKey && !projectId) {
|
||||||
return { hits: [], debug: { vec: 0, fts: 0, tag: 0 } };
|
return { hits: [], debug: { vec: 0, fts: 0, tag: 0 } };
|
||||||
}
|
}
|
||||||
@@ -69,14 +104,28 @@ export async function searchMemories(
|
|||||||
const queryVec = await embedText(query);
|
const queryVec = await embedText(query);
|
||||||
const vecLit = toVectorLiteral(queryVec);
|
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 }[]>`
|
const vecPromise = pg<{ id: string }[]>`
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM memories
|
FROM memories
|
||||||
WHERE user_id = ${userId}
|
WHERE deleted_at IS NULL
|
||||||
AND deleted_at IS NULL
|
|
||||||
AND embedding IS NOT NULL
|
AND embedding IS NOT NULL
|
||||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
${visibilityFragment}
|
||||||
ORDER BY embedding <=> ${vecLit}::vector ASC
|
ORDER BY embedding <=> ${vecLit}::vector ASC
|
||||||
LIMIT ${CANDIDATES}
|
LIMIT ${CANDIDATES}
|
||||||
`;
|
`;
|
||||||
@@ -84,11 +133,10 @@ export async function searchMemories(
|
|||||||
const ftsPromise = pg<{ id: string }[]>`
|
const ftsPromise = pg<{ id: string }[]>`
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM memories, plainto_tsquery('english', ${query}) AS q
|
FROM memories, plainto_tsquery('english', ${query}) AS q
|
||||||
WHERE user_id = ${userId}
|
WHERE deleted_at IS NULL
|
||||||
AND deleted_at IS NULL
|
|
||||||
AND content_tsv @@ q
|
AND content_tsv @@ q
|
||||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
${visibilityFragment}
|
||||||
ORDER BY ts_rank_cd(content_tsv, q) DESC
|
ORDER BY ts_rank_cd(content_tsv, q) DESC
|
||||||
LIMIT ${CANDIDATES}
|
LIMIT ${CANDIDATES}
|
||||||
`;
|
`;
|
||||||
@@ -98,11 +146,10 @@ export async function searchMemories(
|
|||||||
? pg<{ id: string }[]>`
|
? pg<{ id: string }[]>`
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM memories
|
FROM memories
|
||||||
WHERE user_id = ${userId}
|
WHERE deleted_at IS NULL
|
||||||
AND deleted_at IS NULL
|
|
||||||
AND tags && ${tags}::text[]
|
AND tags && ${tags}::text[]
|
||||||
${scope ? pg`AND scope = ${scope}` : pg``}
|
${scope ? pg`AND scope = ${scope}` : pg``}
|
||||||
${projectId ? pg`AND project_id = ${projectId}` : pg``}
|
${visibilityFragment}
|
||||||
ORDER BY cardinality(
|
ORDER BY cardinality(
|
||||||
ARRAY(SELECT unnest(tags) INTERSECT SELECT unnest(${tags}::text[]))
|
ARRAY(SELECT unnest(tags) INTERSECT SELECT unnest(${tags}::text[]))
|
||||||
) DESC
|
) DESC
|
||||||
|
|||||||
+134
-14
@@ -7,12 +7,17 @@ import { auth } from "@/auth";
|
|||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { memories, projects, auditLog } from "@/lib/db/schema";
|
import { memories, projects, auditLog } from "@/lib/db/schema";
|
||||||
import { embedText } from "@/lib/embedder";
|
import { embedText } from "@/lib/embedder";
|
||||||
import { upsertProject } from "@/lib/projects";
|
import { resolveProjectId, upsertProject } from "@/lib/projects";
|
||||||
import {
|
import {
|
||||||
MemoryWriteInput,
|
MemoryWriteInput,
|
||||||
MemoryUpdateInput,
|
MemoryUpdateInput,
|
||||||
MemoryIdInput,
|
MemoryIdInput,
|
||||||
} from "@shared-memory/schemas";
|
} 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
|
* 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.
|
* indistinguishable from those made via Claude Code.
|
||||||
*
|
*
|
||||||
* `actor` is "web" in audit_log so we can tell the two paths apart later.
|
* `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> {
|
async function requireUserId(): Promise<string> {
|
||||||
@@ -38,6 +50,7 @@ function parseTags(raw: FormDataEntryValue | null): string[] {
|
|||||||
|
|
||||||
export async function createMemoryAction(formData: FormData) {
|
export async function createMemoryAction(formData: FormData) {
|
||||||
const userId = await requireUserId();
|
const userId = await requireUserId();
|
||||||
|
const groupNames = await getUserGroupNames(userId);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
content: String(formData.get("content") ?? "").trim(),
|
content: String(formData.get("content") ?? "").trim(),
|
||||||
@@ -53,8 +66,29 @@ export async function createMemoryAction(formData: FormData) {
|
|||||||
let projectId: string | null = null;
|
let projectId: string | null = null;
|
||||||
if (parsed.data.scope === "project") {
|
if (parsed.data.scope === "project") {
|
||||||
if (!parsed.data.project) throw new Error("scope=project requires `project`");
|
if (!parsed.data.project) throw new Error("scope=project requires `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);
|
projectId = await upsertProject(userId, parsed.data.project);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const embedding = await embedText(parsed.data.content);
|
const embedding = await embedText(parsed.data.content);
|
||||||
|
|
||||||
@@ -67,6 +101,7 @@ export async function createMemoryAction(formData: FormData) {
|
|||||||
content: parsed.data.content,
|
content: parsed.data.content,
|
||||||
tags: parsed.data.tags ?? [],
|
tags: parsed.data.tags ?? [],
|
||||||
embedding,
|
embedding,
|
||||||
|
lastEditedBy: userId,
|
||||||
})
|
})
|
||||||
.returning({ id: memories.id });
|
.returning({ id: memories.id });
|
||||||
|
|
||||||
@@ -89,10 +124,16 @@ export async function createMemoryAction(formData: FormData) {
|
|||||||
|
|
||||||
export async function updateMemoryAction(formData: FormData) {
|
export async function updateMemoryAction(formData: FormData) {
|
||||||
const userId = await requireUserId();
|
const userId = await requireUserId();
|
||||||
|
const groupNames = await getUserGroupNames(userId);
|
||||||
|
|
||||||
const id = String(formData.get("id") ?? "");
|
const id = String(formData.get("id") ?? "");
|
||||||
const rawScope = formData.get("scope");
|
const rawScope = formData.get("scope");
|
||||||
const rawProject = (formData.get("project") as string | null)?.trim() || undefined;
|
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 = {
|
const payload = {
|
||||||
id,
|
id,
|
||||||
content: ((formData.get("content") as string | null) ?? "").trim() || undefined,
|
content: ((formData.get("content") as string | null) ?? "").trim() || undefined,
|
||||||
@@ -102,12 +143,16 @@ export async function updateMemoryAction(formData: FormData) {
|
|||||||
? (rawScope as "project" | "user")
|
? (rawScope as "project" | "user")
|
||||||
: undefined,
|
: undefined,
|
||||||
project: rawProject,
|
project: rawProject,
|
||||||
|
version: Number.isFinite(versionNum) ? versionNum : undefined,
|
||||||
};
|
};
|
||||||
const parsed = MemoryUpdateInput.safeParse(payload);
|
const parsed = MemoryUpdateInput.safeParse(payload);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
|
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
|
const existingRows = await db
|
||||||
.select({
|
.select({
|
||||||
id: memories.id,
|
id: memories.id,
|
||||||
@@ -115,17 +160,32 @@ export async function updateMemoryAction(formData: FormData) {
|
|||||||
scope: memories.scope,
|
scope: memories.scope,
|
||||||
projectId: memories.projectId,
|
projectId: memories.projectId,
|
||||||
projectKey: projects.key,
|
projectKey: projects.key,
|
||||||
|
version: memories.version,
|
||||||
|
userId: memories.userId,
|
||||||
})
|
})
|
||||||
.from(memories)
|
.from(memories)
|
||||||
.leftJoin(projects, eq(memories.projectId, projects.id))
|
.leftJoin(projects, eq(memories.projectId, projects.id))
|
||||||
.where(
|
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
|
||||||
and(eq(memories.id, parsed.data.id), eq(memories.userId, userId), isNull(memories.deletedAt)),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const existing = existingRows[0];
|
const existing = existingRows[0];
|
||||||
if (!existing) throw new Error("not found");
|
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.tags !== undefined) update.tags = parsed.data.tags;
|
||||||
if (parsed.data.content !== undefined && parsed.data.content !== existing.content) {
|
if (parsed.data.content !== undefined && parsed.data.content !== existing.content) {
|
||||||
update.content = parsed.data.content;
|
update.content = parsed.data.content;
|
||||||
@@ -148,9 +208,34 @@ export async function updateMemoryAction(formData: FormData) {
|
|||||||
newProjectKey = null;
|
newProjectKey = null;
|
||||||
}
|
}
|
||||||
} else {
|
} 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 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") {
|
if (existing.scope !== "project") {
|
||||||
update.scope = "project";
|
update.scope = "project";
|
||||||
scopeChanged = true;
|
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)
|
.update(memories)
|
||||||
.set(update)
|
.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 };
|
const auditPayload: Record<string, unknown> = { fields: auditFields };
|
||||||
if (scopeChanged || projectChanged) {
|
if (scopeChanged || projectChanged) {
|
||||||
auditPayload.scope = {
|
auditPayload.scope = {
|
||||||
@@ -197,16 +297,36 @@ export async function updateMemoryAction(formData: FormData) {
|
|||||||
|
|
||||||
export async function deleteMemoryAction(formData: FormData) {
|
export async function deleteMemoryAction(formData: FormData) {
|
||||||
const userId = await requireUserId();
|
const userId = await requireUserId();
|
||||||
|
const groupNames = await getUserGroupNames(userId);
|
||||||
const id = String(formData.get("id") ?? "");
|
const id = String(formData.get("id") ?? "");
|
||||||
const parsed = MemoryIdInput.safeParse({ id });
|
const parsed = MemoryIdInput.safeParse({ id });
|
||||||
if (!parsed.success) throw new Error(parsed.error.issues[0]!.message);
|
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
|
const updated = await db
|
||||||
.update(memories)
|
.update(memories)
|
||||||
.set({ deletedAt: new Date() })
|
.set({ deletedAt: new Date(), lastEditedBy: userId })
|
||||||
.where(
|
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
|
||||||
and(eq(memories.id, parsed.data.id), eq(memories.userId, userId), isNull(memories.deletedAt)),
|
|
||||||
)
|
|
||||||
.returning({ id: memories.id });
|
.returning({ id: memories.id });
|
||||||
|
|
||||||
if (!updated[0]) throw new Error("not found");
|
if (!updated[0]) throw new Error("not found");
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SnippetDeleteInput,
|
SnippetDeleteInput,
|
||||||
} from "@shared-memory/schemas";
|
} from "@shared-memory/schemas";
|
||||||
import { putSnippet, softDeleteSnippet } from "@/lib/snippets";
|
import { putSnippet, softDeleteSnippet } from "@/lib/snippets";
|
||||||
|
import { getUserGroupNames } from "@/lib/access";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server Actions for snippet CRUD from the Web UI. Mirrors the MCP
|
* 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.
|
* indistinguishable on the storage layer.
|
||||||
*
|
*
|
||||||
* `actor` is "web" in audit_log so we can tell the two paths apart later.
|
* `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> {
|
async function requireUserId(): Promise<string> {
|
||||||
@@ -41,6 +46,7 @@ function targetUrl(scope: "project" | "user", name: string, projectKey: string |
|
|||||||
|
|
||||||
export async function createSnippetAction(formData: FormData) {
|
export async function createSnippetAction(formData: FormData) {
|
||||||
const userId = await requireUserId();
|
const userId = await requireUserId();
|
||||||
|
const groupNames = await getUserGroupNames(userId);
|
||||||
|
|
||||||
const scope = (formData.get("scope") as "project" | "user") || "user";
|
const scope = (formData.get("scope") as "project" | "user") || "user";
|
||||||
const projectRaw = (formData.get("project") as string | null)?.trim();
|
const projectRaw = (formData.get("project") as string | null)?.trim();
|
||||||
@@ -65,6 +71,7 @@ export async function createSnippetAction(formData: FormData) {
|
|||||||
tags: parsed.data.tags,
|
tags: parsed.data.tags,
|
||||||
scope: parsed.data.scope,
|
scope: parsed.data.scope,
|
||||||
projectKey: parsed.data.project,
|
projectKey: parsed.data.project,
|
||||||
|
groupNames,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(auditLog).values({
|
await db.insert(auditLog).values({
|
||||||
@@ -87,11 +94,17 @@ export async function createSnippetAction(formData: FormData) {
|
|||||||
|
|
||||||
export async function updateSnippetAction(formData: FormData) {
|
export async function updateSnippetAction(formData: FormData) {
|
||||||
const userId = await requireUserId();
|
const userId = await requireUserId();
|
||||||
|
const groupNames = await getUserGroupNames(userId);
|
||||||
|
|
||||||
// Edits keep the row's identity (scope + name + project unchanged) —
|
// Edits keep the row's identity (scope + name + project unchanged) —
|
||||||
// body/description/tags are what changes. Treat as a put on the same key.
|
// body/description/tags are what changes. Treat as a put on the same key.
|
||||||
const scope = (formData.get("scope") as "project" | "user") || "user";
|
const scope = (formData.get("scope") as "project" | "user") || "user";
|
||||||
const projectRaw = (formData.get("project") as string | null)?.trim();
|
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 = {
|
const payload = {
|
||||||
name: String(formData.get("name") ?? "").trim(),
|
name: String(formData.get("name") ?? "").trim(),
|
||||||
body: String(formData.get("body") ?? ""),
|
body: String(formData.get("body") ?? ""),
|
||||||
@@ -99,6 +112,7 @@ export async function updateSnippetAction(formData: FormData) {
|
|||||||
tags: parseTags(formData.get("tags")),
|
tags: parseTags(formData.get("tags")),
|
||||||
scope,
|
scope,
|
||||||
project: scope === "project" ? projectRaw || undefined : undefined,
|
project: scope === "project" ? projectRaw || undefined : undefined,
|
||||||
|
version: Number.isFinite(versionNum) ? versionNum : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = SnippetPutInput.safeParse(payload);
|
const parsed = SnippetPutInput.safeParse(payload);
|
||||||
@@ -113,6 +127,8 @@ export async function updateSnippetAction(formData: FormData) {
|
|||||||
tags: parsed.data.tags,
|
tags: parsed.data.tags,
|
||||||
scope: parsed.data.scope,
|
scope: parsed.data.scope,
|
||||||
projectKey: parsed.data.project,
|
projectKey: parsed.data.project,
|
||||||
|
groupNames,
|
||||||
|
version: parsed.data.version,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(auditLog).values({
|
await db.insert(auditLog).values({
|
||||||
@@ -135,6 +151,7 @@ export async function updateSnippetAction(formData: FormData) {
|
|||||||
|
|
||||||
export async function deleteSnippetAction(formData: FormData) {
|
export async function deleteSnippetAction(formData: FormData) {
|
||||||
const userId = await requireUserId();
|
const userId = await requireUserId();
|
||||||
|
const groupNames = await getUserGroupNames(userId);
|
||||||
|
|
||||||
const scope = formData.get("scope") as "project" | "user" | null;
|
const scope = formData.get("scope") as "project" | "user" | null;
|
||||||
const projectRaw = (formData.get("project") as string | null)?.trim();
|
const projectRaw = (formData.get("project") as string | null)?.trim();
|
||||||
@@ -153,6 +170,7 @@ export async function deleteSnippetAction(formData: FormData) {
|
|||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
scope: parsed.data.scope,
|
scope: parsed.data.scope,
|
||||||
projectKey: parsed.data.project,
|
projectKey: parsed.data.project,
|
||||||
|
groupNames,
|
||||||
});
|
});
|
||||||
if (!deleted) throw new Error("not found");
|
if (!deleted) throw new Error("not found");
|
||||||
|
|
||||||
|
|||||||
+156
-24
@@ -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 { db } from "@/lib/db/client";
|
||||||
import { snippets, projects } from "@/lib/db/schema";
|
import { snippets, projects } from "@/lib/db/schema";
|
||||||
import type { Snippet } 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
|
* 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
|
* `scope` (+ `project` when project-scoped). When `scope` is omitted on
|
||||||
* a get/delete, we prefer the project match (if `project` was supplied)
|
* a get/delete, we prefer the project match (if `project` was supplied)
|
||||||
* else fall back to the user-scope row.
|
* 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 {
|
export interface ResolvedScope {
|
||||||
scope: "project" | "user";
|
scope: "project" | "user";
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
@@ -33,6 +44,31 @@ async function resolveProjectId(userId: string, key: string): Promise<string | n
|
|||||||
return row[0]?.id ?? null;
|
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> {
|
async function upsertProject(userId: string, key: string): Promise<string> {
|
||||||
const existing = await resolveProjectId(userId, key);
|
const existing = await resolveProjectId(userId, key);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
@@ -47,14 +83,20 @@ export interface SnippetWithProjectKey extends Snippet {
|
|||||||
projectKey: string | null;
|
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(
|
async function findSnippet(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
groupNames: string[],
|
||||||
name: string,
|
name: string,
|
||||||
scope: "project" | "user",
|
scope: "project" | "user",
|
||||||
projectId: string | null,
|
projectId: string | null,
|
||||||
): Promise<SnippetWithProjectKey | null> {
|
): Promise<SnippetWithProjectKey | null> {
|
||||||
const where = [
|
const where = [
|
||||||
eq(snippets.userId, userId),
|
|
||||||
eq(snippets.name, name),
|
eq(snippets.name, name),
|
||||||
eq(snippets.scope, scope),
|
eq(snippets.scope, scope),
|
||||||
isNull(snippets.deletedAt),
|
isNull(snippets.deletedAt),
|
||||||
@@ -62,7 +104,13 @@ async function findSnippet(
|
|||||||
if (scope === "project") {
|
if (scope === "project") {
|
||||||
if (!projectId) return null;
|
if (!projectId) return null;
|
||||||
where.push(eq(snippets.projectId, projectId));
|
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 {
|
} else {
|
||||||
|
// User-scope snippet: strictly the caller's own row.
|
||||||
|
where.push(eq(snippets.userId, userId));
|
||||||
where.push(isNull(snippets.projectId));
|
where.push(isNull(snippets.projectId));
|
||||||
}
|
}
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -75,6 +123,8 @@ async function findSnippet(
|
|||||||
body: snippets.body,
|
body: snippets.body,
|
||||||
description: snippets.description,
|
description: snippets.description,
|
||||||
tags: snippets.tags,
|
tags: snippets.tags,
|
||||||
|
version: snippets.version,
|
||||||
|
lastEditedBy: snippets.lastEditedBy,
|
||||||
createdAt: snippets.createdAt,
|
createdAt: snippets.createdAt,
|
||||||
updatedAt: snippets.updatedAt,
|
updatedAt: snippets.updatedAt,
|
||||||
deletedAt: snippets.deletedAt,
|
deletedAt: snippets.deletedAt,
|
||||||
@@ -84,6 +134,9 @@ async function findSnippet(
|
|||||||
.leftJoin(projects, eq(snippets.projectId, projects.id))
|
.leftJoin(projects, eq(snippets.projectId, projects.id))
|
||||||
.where(and(...where))
|
.where(and(...where))
|
||||||
.limit(1);
|
.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;
|
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
|
* Look up a single snippet by name. If `scope` is omitted, prefers a
|
||||||
* project match (when `projectKey` is provided) and falls back to the
|
* project match (when `projectKey` is provided) and falls back to the
|
||||||
* user-scope row. Returns null when nothing matches.
|
* user-scope row. Returns null when nothing matches.
|
||||||
|
*
|
||||||
|
* `groupNames` widens project visibility to include shared projects.
|
||||||
*/
|
*/
|
||||||
export async function getSnippet(
|
export async function getSnippet(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -98,36 +153,48 @@ export async function getSnippet(
|
|||||||
name: string;
|
name: string;
|
||||||
scope?: "project" | "user";
|
scope?: "project" | "user";
|
||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
|
groupNames?: string[];
|
||||||
},
|
},
|
||||||
): Promise<SnippetWithProjectKey | null> {
|
): Promise<SnippetWithProjectKey | null> {
|
||||||
const { name, scope, projectKey } = args;
|
const { name, scope, projectKey, groupNames = [] } = args;
|
||||||
|
|
||||||
if (scope === "project") {
|
if (scope === "project") {
|
||||||
if (!projectKey) return null;
|
if (!projectKey) return null;
|
||||||
const pid = await resolveProjectId(userId, projectKey);
|
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
|
||||||
if (!pid) return null;
|
if (!pid) return null;
|
||||||
return findSnippet(userId, name, "project", pid);
|
return findSnippet(userId, groupNames, name, "project", pid);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "user") {
|
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.
|
// Scope unspecified: try project first if a key was given, then user.
|
||||||
if (projectKey) {
|
if (projectKey) {
|
||||||
const pid = await resolveProjectId(userId, projectKey);
|
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
|
||||||
if (pid) {
|
if (pid) {
|
||||||
const projectHit = await findSnippet(userId, name, "project", pid);
|
const projectHit = await findSnippet(userId, groupNames, name, "project", pid);
|
||||||
if (projectHit) return projectHit;
|
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
|
* Upsert a snippet keyed by (scope, project, name). If a live row with
|
||||||
* already exists (live, matching scope), it's replaced in place
|
* that key already exists, it's replaced in place — preserving its id
|
||||||
* preserving its id. Returns the resulting row plus an `inserted` flag.
|
* 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(
|
export async function putSnippet(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -138,29 +205,56 @@ export async function putSnippet(
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
scope: "project" | "user";
|
scope: "project" | "user";
|
||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
|
groupNames?: string[];
|
||||||
|
version?: number;
|
||||||
},
|
},
|
||||||
): Promise<{ snippet: SnippetWithProjectKey; inserted: boolean }> {
|
): 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;
|
let projectId: string | null = null;
|
||||||
if (scope === "project") {
|
if (scope === "project") {
|
||||||
if (!projectKey) throw new Error("scope=project requires projectKey");
|
if (!projectKey) throw new Error("scope=project requires 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);
|
projectId = await upsertProject(userId, projectKey);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await findSnippet(userId, name, scope, projectId);
|
const existing = await findSnippet(userId, groupNames, name, scope, projectId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const updateValues: Record<string, unknown> = {
|
const updateValues: Record<string, unknown> = {
|
||||||
body,
|
body,
|
||||||
tags: tags ?? existing.tags,
|
tags: tags ?? existing.tags,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
version: existing.version + 1,
|
||||||
|
lastEditedBy: userId,
|
||||||
};
|
};
|
||||||
if (description !== undefined) updateValues.description = description;
|
if (description !== undefined) updateValues.description = description;
|
||||||
await db
|
const expectedVersion = version ?? existing.version;
|
||||||
|
const updated = await db
|
||||||
.update(snippets)
|
.update(snippets)
|
||||||
.set(updateValues)
|
.set(updateValues)
|
||||||
.where(and(eq(snippets.id, existing.id), eq(snippets.userId, userId)));
|
.where(and(eq(snippets.id, existing.id), eq(snippets.version, expectedVersion)))
|
||||||
const refreshed = await findSnippet(userId, name, scope, projectId);
|
.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 };
|
return { snippet: refreshed!, inserted: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +268,7 @@ export async function putSnippet(
|
|||||||
body,
|
body,
|
||||||
description: description ?? null,
|
description: description ?? null,
|
||||||
tags: tags ?? [],
|
tags: tags ?? [],
|
||||||
|
lastEditedBy: userId,
|
||||||
})
|
})
|
||||||
.returning({ id: snippets.id });
|
.returning({ id: snippets.id });
|
||||||
|
|
||||||
@@ -187,6 +282,8 @@ export async function putSnippet(
|
|||||||
body: snippets.body,
|
body: snippets.body,
|
||||||
description: snippets.description,
|
description: snippets.description,
|
||||||
tags: snippets.tags,
|
tags: snippets.tags,
|
||||||
|
version: snippets.version,
|
||||||
|
lastEditedBy: snippets.lastEditedBy,
|
||||||
createdAt: snippets.createdAt,
|
createdAt: snippets.createdAt,
|
||||||
updatedAt: snippets.updatedAt,
|
updatedAt: snippets.updatedAt,
|
||||||
deletedAt: snippets.deletedAt,
|
deletedAt: snippets.deletedAt,
|
||||||
@@ -201,9 +298,13 @@ export async function putSnippet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List live snippets for this user, newest first. Filters mirror
|
* List live snippets visible to this user, newest first. Visibility:
|
||||||
* memory.list. No pagination cursor yet — snippets are expected to be
|
* - user-scope rows owned by `userId`
|
||||||
* relatively low-volume; we cap at the requested limit.
|
* - 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(
|
export async function listSnippets(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -212,15 +313,28 @@ export async function listSnippets(
|
|||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
groupNames?: string[];
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<SnippetWithProjectKey[]> {
|
): Promise<SnippetWithProjectKey[]> {
|
||||||
const { scope, projectKey, tags, limit = 50 } = args;
|
const { scope, projectKey, tags, limit = 50, groupNames = [] } = args;
|
||||||
const where = [eq(snippets.userId, userId), isNull(snippets.deletedAt)];
|
|
||||||
|
// 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 (scope) where.push(eq(snippets.scope, scope));
|
||||||
|
|
||||||
if (projectKey) {
|
if (projectKey) {
|
||||||
const pid = await resolveProjectId(userId, projectKey);
|
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
|
||||||
if (!pid) return [];
|
if (!pid) return [];
|
||||||
where.push(eq(snippets.projectId, pid));
|
where.push(eq(snippets.projectId, pid));
|
||||||
}
|
}
|
||||||
@@ -239,6 +353,8 @@ export async function listSnippets(
|
|||||||
body: snippets.body,
|
body: snippets.body,
|
||||||
description: snippets.description,
|
description: snippets.description,
|
||||||
tags: snippets.tags,
|
tags: snippets.tags,
|
||||||
|
version: snippets.version,
|
||||||
|
lastEditedBy: snippets.lastEditedBy,
|
||||||
createdAt: snippets.createdAt,
|
createdAt: snippets.createdAt,
|
||||||
updatedAt: snippets.updatedAt,
|
updatedAt: snippets.updatedAt,
|
||||||
deletedAt: snippets.deletedAt,
|
deletedAt: snippets.deletedAt,
|
||||||
@@ -259,6 +375,9 @@ export async function listSnippets(
|
|||||||
*
|
*
|
||||||
* If `scope` is omitted and `projectKey` is provided, deletes the
|
* If `scope` is omitted and `projectKey` is provided, deletes the
|
||||||
* project-scope row (if found) — falls back to user-scope otherwise.
|
* 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(
|
export async function softDeleteSnippet(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -266,14 +385,27 @@ export async function softDeleteSnippet(
|
|||||||
name: string;
|
name: string;
|
||||||
scope?: "project" | "user";
|
scope?: "project" | "user";
|
||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
|
groupNames?: string[];
|
||||||
},
|
},
|
||||||
): Promise<{ id: string; scope: "project" | "user"; projectKey: string | null } | null> {
|
): Promise<{ id: string; scope: "project" | "user"; projectKey: string | null } | null> {
|
||||||
|
const { groupNames = [] } = args;
|
||||||
const target = await getSnippet(userId, args);
|
const target = await getSnippet(userId, args);
|
||||||
if (!target) return null;
|
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
|
await db
|
||||||
.update(snippets)
|
.update(snippets)
|
||||||
.set({ deletedAt: new Date() })
|
.set({ deletedAt: new Date(), lastEditedBy: userId })
|
||||||
.where(eq(snippets.id, target.id));
|
.where(eq(snippets.id, target.id));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export type MemoryScope = z.infer<typeof MemoryScope>;
|
|||||||
export const MemoryVisibility = z.enum(["private", "shared", "team"]);
|
export const MemoryVisibility = z.enum(["private", "shared", "team"]);
|
||||||
export type MemoryVisibility = z.infer<typeof MemoryVisibility>;
|
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
|
export const ProjectKey = z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
@@ -48,6 +53,10 @@ export const MemoryUpdateInput = z.object({
|
|||||||
tags: Tags.optional(),
|
tags: Tags.optional(),
|
||||||
scope: MemoryScope.optional(),
|
scope: MemoryScope.optional(),
|
||||||
project: ProjectKey.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(
|
.refine(
|
||||||
(v) =>
|
(v) =>
|
||||||
@@ -123,6 +132,9 @@ export const SnippetPutInput = z
|
|||||||
tags: Tags.optional(),
|
tags: Tags.optional(),
|
||||||
scope: MemoryScope.default("user"),
|
scope: MemoryScope.default("user"),
|
||||||
project: ProjectKey.optional(),
|
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 });
|
.refine(scopeProjectRefinement.check, { message: scopeProjectRefinement.message });
|
||||||
export type SnippetPutInput = z.infer<typeof SnippetPutInput>;
|
export type SnippetPutInput = z.infer<typeof SnippetPutInput>;
|
||||||
|
|||||||
Reference in New Issue
Block a user