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

# Conflicts:
#	apps/web/lib/db/schema.ts
#	apps/web/lib/mcp/context.ts
This commit is contained in:
2026-05-17 09:54:48 -07:00
16 changed files with 1885 additions and 250 deletions
+210
View File
@@ -0,0 +1,210 @@
import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
groups,
projects,
projectShares,
userGroups,
} from "@/lib/db/schema";
/**
* Authorization helpers for the project-sharing model.
*
* Access semantics:
* - Owner (projects.user_id = U.id): full read + write.
* - Group share (project_shares.group_id in U.groups):
* access='ro' → read only
* access='rw' → read + write
*
* Lookups in this module are intentionally cheap and small — they only
* resolve project_ids the user can touch. Per-row queries embed those
* ids in their WHERE clauses (or use IN subqueries) so the database still
* does the heavy lifting; we never load all-of-project-X into memory to
* filter in JS.
*
* Why a separate module: callers come from three places
* (`memory-actions`, `snippet-actions`, `mcp/tools`, plus the `lib/`
* search/list helpers), and replicating the same SQL three ways was
* the previous source of inconsistency this phase fixes.
*/
export type ProjectAccess = "owner" | "ro" | "rw";
export interface AccessibleProject {
projectId: string;
access: ProjectAccess;
projectKey: string;
}
/**
* Resolve the set of project ids `userId` can read, with the strongest
* access level for each. Owner > rw > ro. Used by listing/search paths
* that need to widen their WHERE clauses to include shared projects.
*
* Group names are matched case-sensitively against the `groups` table —
* the OIDC claim names are the contract. An empty `groupNames` is fine;
* the user just won't see any shared projects.
*/
export async function getAccessibleProjects(
userId: string,
groupNames: string[],
): Promise<AccessibleProject[]> {
const owned = await db
.select({ projectId: projects.id, projectKey: projects.key })
.from(projects)
.where(eq(projects.userId, userId));
const ownedMap = new Map<string, AccessibleProject>(
owned.map((r) => ({
projectId: r.projectId,
access: "owner" as const,
projectKey: r.projectKey,
})).map((r) => [r.projectId, r] as const),
);
if (groupNames.length === 0) {
return [...ownedMap.values()];
}
// Join project_shares → groups → projects so we get the project key
// alongside the access level in a single query.
const shared = await db
.select({
projectId: projectShares.projectId,
access: projectShares.access,
projectKey: projects.key,
})
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.innerJoin(projects, eq(projects.id, projectShares.projectId))
.where(inArray(groups.name, groupNames));
for (const r of shared) {
const existing = ownedMap.get(r.projectId);
if (existing) continue; // owner already wins
// If two of the user's groups both share the same project at
// different levels, keep the stronger one (rw beats ro).
const prior = ownedMap.get(r.projectId);
if (prior && prior.access === "rw") continue;
ownedMap.set(r.projectId, {
projectId: r.projectId,
access: r.access as "ro" | "rw",
projectKey: r.projectKey,
});
}
return [...ownedMap.values()];
}
/**
* Resolve project access for a single project_id. Returns null when the
* user has no access at all (deny by default). Owner check is short-
* circuited: we don't query project_shares unless the user isn't owner.
*/
export async function getProjectAccess(
userId: string,
groupNames: string[],
projectId: string,
): Promise<ProjectAccess | null> {
const owned = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.userId, userId)))
.limit(1);
if (owned[0]) return "owner";
if (groupNames.length === 0) return null;
const sharedRows = await db
.select({ access: projectShares.access })
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.where(
and(
eq(projectShares.projectId, projectId),
inArray(groups.name, groupNames),
),
);
if (sharedRows.length === 0) return null;
// If a user is in multiple groups with different levels on the same
// project, pick the strongest.
return sharedRows.some((r) => r.access === "rw") ? "rw" : "ro";
}
/**
* "Can this user read project P?" — true for owner, ro, or rw.
*/
export async function canReadProject(
userId: string,
groupNames: string[],
projectId: string,
): Promise<boolean> {
const access = await getProjectAccess(userId, groupNames, projectId);
return access !== null;
}
/**
* "Can this user write to project P?" — true for owner or rw share.
*/
export async function canWriteProject(
userId: string,
groupNames: string[],
projectId: string,
): Promise<boolean> {
const access = await getProjectAccess(userId, groupNames, projectId);
return access === "owner" || access === "rw";
}
/**
* Project ids that this user has READ access to (own + any shared). Used
* by candidate-fetch WHERE clauses on listings and search. The empty
* set is encoded explicitly: callers should treat it as "no rows".
*/
export async function readableProjectIds(
userId: string,
groupNames: string[],
): Promise<string[]> {
const all = await getAccessibleProjects(userId, groupNames);
return all.map((p) => p.projectId);
}
/**
* Project ids that this user has WRITE access to (own + rw shares).
*/
export async function writableProjectIds(
userId: string,
groupNames: string[],
): Promise<string[]> {
const all = await getAccessibleProjects(userId, groupNames);
return all.filter((p) => p.access !== "ro").map((p) => p.projectId);
}
/**
* The error message returned to any caller that lost an optimistic-
* locking race. Centralized so the wording stays consistent across MCP
* tools and Server Actions; callers also key off the prefix to surface
* a "Refresh" UI affordance if they care.
*/
export const CONCURRENT_EDIT_ERROR =
"Memory was modified by someone else since you loaded it. Refresh and try again.";
export const CONCURRENT_EDIT_ERROR_SNIPPET =
"Snippet was modified by someone else since you loaded it. Refresh and try again.";
/**
* Fetch the group names this user is currently a member of from the
* `user_groups` table. Used by Web UI Server Actions and pages — the
* web session's JWT may carry the same list, but reading from the DB
* means we don't have to coordinate with Agent A's session-callback
* change to consume sharing semantics here. Agent A's sign-in callback
* keeps `user_groups` in sync with the OIDC `groups` claim.
*/
export async function getUserGroupNames(userId: string): Promise<string[]> {
const rows = await db
.select({ name: groups.name })
.from(userGroups)
.innerJoin(groups, eq(groups.id, userGroups.groupId))
.where(eq(userGroups.userId, userId));
return rows.map((r) => r.name);
}
+55 -6
View File
@@ -11,6 +11,7 @@ import {
customType,
vector,
varchar,
integer,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
@@ -38,9 +39,8 @@ const textArray = customType<{ data: string[]; driverData: string }>({
export const memoryScope = pgEnum("memory_scope", ["project", "user"]);
export const memoryVisibility = pgEnum("memory_visibility", ["private", "shared", "team"]);
export const auditActor = pgEnum("audit_actor", ["mcp", "web", "system"]);
// Reserved here so 0003 (this file's matching migration) owns it. Used
// by Agent B's upcoming `project_shares` table to express RO vs RW
// grants per shared group.
// Created by 0003_groups.sql; declared here so the TS layer (notably
// `project_shares`) can reference it as a typed pgEnum.
export const memoryAccess = pgEnum("memory_access", ["ro", "rw"]);
// ---------- tables ----------
@@ -49,7 +49,7 @@ export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
// OIDC `sub` claim from Authentik — stable identifier for this user.
// OIDC `sub` claim from the IdP — stable identifier for this user.
oidcSub: text("oidc_sub").notNull(),
// OIDC `iss` so we can disambiguate if we ever federate.
oidcIss: text("oidc_iss").notNull(),
@@ -99,6 +99,14 @@ export const memories = pgTable(
embedding: vector("embedding", { dimensions: 384 }),
// Generated column — see migration SQL for definition.
contentTsv: tsvector("content_tsv"),
// Optimistic-locking counter. Bumped on every successful UPDATE so
// concurrent edits (now possible across shared-project members) can
// detect lost-write situations and surface "refresh and try again".
version: integer("version").notNull().default(1),
// The user whose UPDATE most recently mutated this row. NULL only on
// the very first INSERT (pre-update). FK is `SET NULL` so deleting
// an account doesn't wipe other people's memories.
lastEditedBy: uuid("last_edited_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
@@ -126,6 +134,10 @@ export const snippets = pgTable(
body: text("body").notNull(),
description: text("description"),
tags: textArray("tags").notNull().default([]),
// See `memories.version` / `memories.lastEditedBy` — co-edit primitive
// for snippets in shared projects.
version: integer("version").notNull().default(1),
lastEditedBy: uuid("last_edited_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
@@ -157,6 +169,14 @@ export const cliTokens = pgTable(
}),
);
// ---------- groups + sharing ----------
//
// `groups` and `user_groups` come from `0003_groups.sql`; `project_shares`
// comes from `0004_project_shares.sql`. Drizzle declarations here let
// authorization helpers and the share-management UI import everything
// through `@/lib/db/schema`. Column shape MUST stay in lockstep with the
// migrations.
export const groups = pgTable(
"groups",
{
@@ -164,9 +184,8 @@ export const groups = pgTable(
// OIDC issuer this group originates from — pairs with `name` so two
// IdPs can both have a "platform" group without collision.
oidcIss: text("oidc_iss").notNull(),
// Group `name` as it appears in the JWT (Authentik / EntraID groups claim).
name: text("name").notNull(),
// Optional human-friendly label. Most IdPs only emit names, so this
// is usually NULL; reserved for future enrichment.
displayName: text("display_name"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
@@ -191,6 +210,34 @@ export const userGroups = pgTable(
(t) => ({
pk: primaryKey({ columns: [t.userId, t.groupId] }),
userIdx: index("user_groups_user_idx").on(t.userId),
groupIdx: index("user_groups_group_idx").on(t.groupId),
}),
);
// `project_shares` grants a `group` access to a `project`. Each row
// authorizes every user in that group to read (and, when access='rw',
// write) every memory + snippet under that project.
//
// Owners share projects from the Web UI; the MCP layer can resolve
// shared projects via project.identify but cannot grant new shares.
export const projectShares = pgTable(
"project_shares",
{
projectId: uuid("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
groupId: uuid("group_id")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
access: memoryAccess("access").notNull(),
grantedAt: timestamp("granted_at", { withTimezone: true }).notNull().defaultNow(),
// Audit-friendly. `SET NULL` so deleting the granter's account doesn't
// cascade-remove the share.
grantedBy: uuid("granted_by").references(() => users.id, { onDelete: "set null" }),
},
(t) => ({
pk: primaryKey({ columns: [t.projectId, t.groupId] }),
groupIdx: index("project_shares_group_idx").on(t.groupId),
}),
);
@@ -234,3 +281,5 @@ export type Group = typeof groups.$inferSelect;
export type NewGroup = typeof groups.$inferInsert;
export type UserGroup = typeof userGroups.$inferSelect;
export type NewUserGroup = typeof userGroups.$inferInsert;
export type ProjectShare = typeof projectShares.$inferSelect;
export type NewProjectShare = typeof projectShares.$inferInsert;
+7 -2
View File
@@ -8,6 +8,12 @@ import type { AuthenticatedClaims } from "@/lib/auth/jwt";
*
* Resolves (or creates) the internal `users` row from the OIDC claims so
* tools work with stable UUID foreign keys rather than raw `sub` strings.
*
* `groups` and `defaultProjectKey` are populated here from the inbound
* request: groups come from the JWT's `groups` claim (live) with a DB
* fallback for CLI tokens that carry no claim; defaultProjectKey is the
* `X-Project-Key` header (already Zod-validated at the route boundary),
* used as a fallback when a tool call omits `project`.
*/
export interface UserContext {
/** Internal users.id UUID. */
@@ -85,8 +91,7 @@ export async function userContextFromClaims(
// emit it). CLI tokens never do — they go through verifyCliToken which
// doesn't set claims.groups. In that case fall back to the DB snapshot
// from the user's last interactive sign-in.
const groupNames =
claims.groups ?? (await loadUserGroups(userId));
const groupNames = claims.groups ?? (await loadUserGroups(userId));
return {
userId,
+390 -123
View File
@@ -1,6 +1,12 @@
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { memories, projects, auditLog } from "@/lib/db/schema";
import {
memories,
projects,
projectShares,
groups,
auditLog,
} from "@/lib/db/schema";
import {
MemoryIdInput,
MemoryListInput,
@@ -21,6 +27,12 @@ import {
listSnippets,
softDeleteSnippet,
} from "@/lib/snippets";
import {
CONCURRENT_EDIT_ERROR,
canWriteProject,
getProjectAccess,
readableProjectIds,
} from "@/lib/access";
import type { UserContext } from "./context";
/**
@@ -60,17 +72,46 @@ function err(message: string): ToolResult {
};
}
/**
* Resolve a project_id for a project key visible to this user. Prefers
* an owned project, falls back to any project shared with one of the
* user's groups (any access level — read is enough to resolve the id).
* Returns null when no visible project matches.
*/
async function resolveProjectId(
ctx: UserContext,
projectKey: string | undefined,
): Promise<string | null> {
if (!projectKey) return null;
const row = await db
const owned = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.userId, ctx.userId), eq(projects.key, projectKey)))
.limit(1);
return row[0]?.id ?? null;
if (owned[0]) return owned[0].id;
if (ctx.groups.length === 0) return null;
const accessibleIds = await readableProjectIds(ctx.userId, ctx.groups);
if (accessibleIds.length === 0) return null;
const shared = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.key, projectKey), inArray(projects.id, accessibleIds)))
.limit(1);
return shared[0]?.id ?? null;
}
/**
* Resolve the project key from a tool call: explicit `project` arg takes
* precedence; otherwise fall back to the context-level default (the
* `X-Project-Key` header parsed by the MCP route).
*/
function projectKeyOrDefault(
ctx: UserContext,
arg: string | undefined,
): string | undefined {
return arg ?? ctx.defaultProjectKey;
}
/**
@@ -114,7 +155,7 @@ function withDefaultProject(
const projectIdentify: ToolDef = {
name: "project.identify",
description:
"Call ONCE near the start of every session that has a project context — a repo you're working in, a service you're debugging, etc. — to register or look up that project so subsequent project-scoped memories attach correctly. Use a stable `key` you can reproduce next session (repo name, repo URL, or working directory basename). Skip if the work is purely scratch / not tied to a specific codebase.",
"Call ONCE near the start of every session that has a project context — a repo you're working in, a service you're debugging, etc. — to register or look up that project so subsequent project-scoped memories attach correctly. Use a stable `key` you can reproduce next session (repo name, repo URL, or working directory basename). Returns shared projects you have access to in addition to your own; when an owned and a shared project would both match the same key, the owned one wins (a server-side warning is logged so the collision is debuggable). Skip if the work is purely scratch / not tied to a specific codebase.",
inputSchema: {
type: "object",
properties: {
@@ -134,20 +175,121 @@ const projectIdentify: ToolDef = {
const parsed = ProjectIdentifyInput.safeParse(args);
if (!parsed.success) return err(parsed.error.message);
const row = await db
// 1) Owned project with this key wins.
const ownedRow = await db
.select({
id: projects.id,
key: projects.key,
displayName: projects.displayName,
createdAt: projects.createdAt,
userId: projects.userId,
})
.from(projects)
.where(and(eq(projects.userId, ctx.userId), eq(projects.key, parsed.data.key)))
.limit(1);
// 2) If the user belongs to any groups, find shared projects with
// this key. We collect ALL matches because we need to (a) detect
// a collision with the owned match to emit the warning, and
// (b) collapse the access level across the user's groups.
let sharedMatches: Array<{
projectId: string;
displayName: string | null;
createdAt: Date;
ownerUserId: string;
access: "ro" | "rw";
}> = [];
if (ctx.groups.length > 0) {
const rows = await db
.select({
projectId: projects.id,
displayName: projects.displayName,
createdAt: projects.createdAt,
ownerUserId: projects.userId,
access: projectShares.access,
})
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.innerJoin(projects, eq(projects.id, projectShares.projectId))
.where(
and(
inArray(groups.name, ctx.groups),
eq(projects.key, parsed.data.key),
),
);
sharedMatches = rows as typeof sharedMatches;
}
if (ownedRow[0]) {
// Owned beats shared — but if there's a shared collision, audit
// the warning so an operator can see the ambiguity in the log
// surface. We don't surface it on the caller's response.
const collidesWithShared = sharedMatches.some(
(s) => s.projectId !== ownedRow[0]!.id,
);
if (collidesWithShared) {
await db.insert(auditLog).values({
userId: ctx.userId,
actor: "system",
action: "project.identify.collision",
entityType: "project",
entityId: ownedRow[0].id,
payload: {
projectKey: parsed.data.key,
ownedProjectId: ownedRow[0].id,
sharedProjectIds: sharedMatches.map((s) => s.projectId),
note: "owned project preferred over shared collision",
},
});
}
// Apply display_name update only on the owned project.
if (parsed.data.display_name) {
await db
.update(projects)
.set({ displayName: parsed.data.display_name, updatedAt: new Date() })
.where(eq(projects.id, ownedRow[0].id));
}
return ok(
{
id: ownedRow[0].id,
key: ownedRow[0].key,
displayName: parsed.data.display_name ?? ownedRow[0].displayName,
createdAt: ownedRow[0].createdAt,
shared: false,
access: "owner" as const,
readOnly: false,
},
`project ${ownedRow[0].key} (${ownedRow[0].id})`,
);
}
if (sharedMatches.length > 0) {
// Collapse to the strongest access level across the user's groups.
const access = sharedMatches.some((s) => s.access === "rw") ? "rw" : "ro";
// De-dupe — multiple group rows can point at the same project.
const first = sharedMatches[0]!;
return ok(
{
id: first.projectId,
key: parsed.data.key,
displayName: first.displayName,
createdAt: first.createdAt,
shared: true,
access,
readOnly: access === "ro",
},
`project ${parsed.data.key} (shared, ${access})`,
);
}
// 3) Nothing matched — create a new owned project.
const created = await db
.insert(projects)
.values({
userId: ctx.userId,
key: parsed.data.key,
displayName: parsed.data.display_name ?? null,
})
.onConflictDoUpdate({
target: [projects.userId, projects.key],
set: {
displayName: parsed.data.display_name ?? sql`${projects.displayName}`,
updatedAt: new Date(),
},
})
.returning({
id: projects.id,
key: projects.key,
@@ -155,22 +297,34 @@ const projectIdentify: ToolDef = {
createdAt: projects.createdAt,
});
const p = row[0]!;
return ok(p, `project ${p.key} (${p.id})`);
const p = created[0]!;
return ok(
{
id: p.id,
key: p.key,
displayName: p.displayName,
createdAt: p.createdAt,
shared: false,
access: "owner" as const,
readOnly: false,
},
`project ${p.key} (${p.id})`,
);
},
};
const memoryWrite: ToolDef = {
name: "memory.write",
description:
"Save a durable fact, preference, or decision that ANY future Claude Code session on ANY of this user's machines should know. Call this when the user shares something that meets ALL of: (1) likely to matter beyond this conversation, (2) not derivable from reading current code/git, (3) would surprise a future you if forgotten. Examples: 'I use HAProxy at home' (user-scope), 'we chose Drizzle over Prisma because of bundle size' (project-scope), 'our prod DB is at db.example.com' (user-scope reference). Use scope='user' for facts about the human or their infra; scope='project' for facts tied to a specific codebase (always preceded by project.identify). Sensitive info (API keys, credentials, connection strings the user actively shares with you) IS appropriate to save here — this server is OIDC-gated and per-user; safer than writing to local container files. DO NOT use for: transient task state, this-session-only scratch notes, or container-specific facts (those belong in the built-in file-based memory at ~/.claude/.../memory/). Tags help retrieval.",
"Save a durable fact, preference, or decision that ANY future Claude Code session on ANY of this user's machines should know. Call this when the user shares something that meets ALL of: (1) likely to matter beyond this conversation, (2) not derivable from reading current code/git, (3) would surprise a future you if forgotten. Examples: 'I use HAProxy at home' (user-scope), 'we chose Drizzle over Prisma because of bundle size' (project-scope), 'our prod DB is at db.example.com' (user-scope reference). Use scope='user' for facts about the human or their infra; scope='project' for facts tied to a specific codebase (always preceded by project.identify). In shared projects (i.e. ones surfaced by project.identify with `shared: true`), anyone with rw access can write — your memory becomes visible to every member of every group the project is shared with. Defaults `project` to the X-Project-Key header value if not supplied. Sensitive info (API keys, credentials, connection strings the user actively shares with you) IS appropriate to save here — this server is OIDC-gated and per-user; safer than writing to local container files. DO NOT use for: transient task state, this-session-only scratch notes, or container-specific facts (those belong in the built-in file-based memory at ~/.claude/.../memory/). Tags help retrieval.",
inputSchema: {
type: "object",
properties: {
content: { type: "string", description: "Memory content (164,000 chars)." },
project: {
type: "string",
description: "Project key (required when scope='project').",
description:
"Project key. Required when scope='project'; defaults to the X-Project-Key request header if present.",
},
scope: {
type: "string",
@@ -191,11 +345,20 @@ const memoryWrite: ToolDef = {
const scope = parsed.data.scope;
let projectId: string | null = null;
let projectKey: string | undefined = undefined;
if (scope === "project") {
if (!parsed.data.project) return err("scope=project requires `project` key");
projectId = await resolveProjectId(ctx, parsed.data.project);
projectKey = projectKeyOrDefault(ctx, parsed.data.project);
if (!projectKey) {
return err("scope=project requires `project` key (or X-Project-Key header)");
}
projectId = await resolveProjectId(ctx, projectKey);
if (!projectId) {
return err(`unknown project '${parsed.data.project}'; call project.identify first`);
return err(`unknown project '${projectKey}'; call project.identify first`);
}
// Authorize write. Owner always allowed; otherwise require rw.
const allowed = await canWriteProject(ctx.userId, ctx.groups, projectId);
if (!allowed) {
return err(`no write access to project '${projectKey}'`);
}
}
@@ -214,6 +377,7 @@ const memoryWrite: ToolDef = {
content: parsed.data.content,
tags: parsed.data.tags ?? [],
embedding,
lastEditedBy: ctx.userId,
})
.returning({ id: memories.id, createdAt: memories.createdAt });
@@ -224,7 +388,7 @@ const memoryWrite: ToolDef = {
action: "memory.write",
entityType: "memory",
entityId: m.id,
payload: { scope, projectKey: parsed.data.project ?? null, tags: parsed.data.tags ?? [] },
payload: { scope, projectKey: projectKey ?? null, tags: parsed.data.tags ?? [] },
});
return ok({ id: m.id, createdAt: m.createdAt }, `wrote memory ${m.id}`);
@@ -252,12 +416,20 @@ const memoryList: ToolDef = {
const parsed = MemoryListInput.safeParse(withDefaultProject(args, ctx));
if (!parsed.success) return err(parsed.error.message);
const where = [eq(memories.userId, ctx.userId), isNull(memories.deletedAt)];
// Visibility: own rows OR rows in any project shared with my groups.
const accessibleIds = await readableProjectIds(ctx.userId, ctx.groups);
const visibilityClause =
accessibleIds.length > 0
? or(eq(memories.userId, ctx.userId), inArray(memories.projectId, accessibleIds))
: eq(memories.userId, ctx.userId);
const where = [visibilityClause!, isNull(memories.deletedAt)];
if (parsed.data.scope) where.push(eq(memories.scope, parsed.data.scope));
if (parsed.data.project) {
const projectId = await resolveProjectId(ctx, parsed.data.project);
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
if (requestedKey) {
const projectId = await resolveProjectId(ctx, requestedKey);
if (!projectId) return ok({ items: [], next_cursor: null }, "0 results");
where.push(eq(memories.projectId, projectId));
}
@@ -273,6 +445,8 @@ const memoryList: ToolDef = {
projectId: memories.projectId,
content: memories.content,
tags: memories.tags,
version: memories.version,
lastEditedBy: memories.lastEditedBy,
createdAt: memories.createdAt,
updatedAt: memories.updatedAt,
})
@@ -301,17 +475,21 @@ const memoryGet: ToolDef = {
const row = await db
.select()
.from(memories)
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.userId, ctx.userId),
isNull(memories.deletedAt),
),
)
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
if (!row[0]) return err("not found");
return ok(row[0], `memory ${row[0].id}`);
// Authorize read: own row, OR project-scope row in an accessible
// project. Anything else looks "not found" to the caller.
const m = row[0];
if (m.userId !== ctx.userId) {
if (!m.projectId) return err("not found");
const access = await getProjectAccess(ctx.userId, ctx.groups, m.projectId);
if (access === null) return err("not found");
}
return ok(m, `memory ${m.id}`);
},
};
@@ -328,16 +506,34 @@ const memoryDelete: ToolDef = {
const parsed = MemoryIdInput.safeParse(args);
if (!parsed.success) return err(parsed.error.message);
// Look up the row first to authorize. We can't rely on a
// single-statement WHERE clause because shared-project writes
// need a per-project access check.
const target = await db
.select({
id: memories.id,
userId: memories.userId,
projectId: memories.projectId,
scope: memories.scope,
})
.from(memories)
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
const m = target[0];
if (!m) return err("not found");
if (m.userId !== ctx.userId) {
// Not the owner. User-scope memories can only be deleted by their
// owner; project-scope require rw access on the project.
if (m.scope === "user" || !m.projectId) return err("not found");
const allowed = await canWriteProject(ctx.userId, ctx.groups, m.projectId);
if (!allowed) return err("no write access to this project");
}
const updated = await db
.update(memories)
.set({ deletedAt: new Date() })
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.userId, ctx.userId),
isNull(memories.deletedAt),
),
)
.set({ deletedAt: new Date(), lastEditedBy: ctx.userId })
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.returning({ id: memories.id });
if (!updated[0]) return err("not found");
@@ -357,7 +553,7 @@ const memoryDelete: ToolDef = {
const memoryUpdate: ToolDef = {
name: "memory.update",
description:
"Edit an existing memory in place — for correcting a stored fact, expanding it with new detail, adjusting tags, or moving it to a different scope/project. Preserves the memory's id (so callers referencing it don't break) and re-embeds automatically when content changes. Pass `scope` and/or `project` to reclassify a memory between user-global and project-attached without recreating it. Use this — not delete + write — whenever you're refining what's already there.",
"Edit an existing memory in place — for correcting a stored fact, expanding it with new detail, adjusting tags, or moving it to a different scope/project. Preserves the memory's id (so callers referencing it don't break) and re-embeds automatically when content changes. Pass `scope` and/or `project` to reclassify a memory between user-global and project-attached without recreating it. In shared projects, anyone in a rw-access group can edit any memory — pass `version` (returned by memory.get / memory.list) to detect concurrent edits and avoid clobbering. The server bumps `version` on every successful update; a stale `version` returns the concurrent-edit error. Use this — not delete + write — whenever you're refining what's already there.",
inputSchema: {
type: "object",
properties: {
@@ -375,6 +571,12 @@ const memoryUpdate: ToolDef = {
description:
"Project key the memory should attach to (required and only valid when scope='project'). The project must already exist — call `project.identify` first if it doesn't.",
},
version: {
type: "integer",
minimum: 0,
description:
"Optimistic-locking token from memory.get / memory.list. When supplied, the update is rejected if the row was edited by someone else since you read it.",
},
},
required: ["id"],
},
@@ -389,21 +591,29 @@ const memoryUpdate: ToolDef = {
scope: memories.scope,
projectId: memories.projectId,
projectKey: projects.key,
version: memories.version,
userId: memories.userId,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.userId, ctx.userId),
isNull(memories.deletedAt),
),
)
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
const existing = existingRows[0];
if (!existing) return err("not found");
const update: Record<string, unknown> = { updatedAt: new Date() };
// Authorize write.
if (existing.scope === "user") {
if (existing.userId !== ctx.userId) return err("not found");
} else if (existing.projectId) {
const allowed = await canWriteProject(ctx.userId, ctx.groups, existing.projectId);
if (!allowed) return err("no write access to this project");
}
const update: Record<string, unknown> = {
updatedAt: new Date(),
lastEditedBy: ctx.userId,
version: existing.version + 1,
};
if (parsed.data.tags !== undefined) update.tags = parsed.data.tags;
if (parsed.data.content !== undefined && parsed.data.content !== existing.content) {
update.content = parsed.data.content;
@@ -426,12 +636,17 @@ const memoryUpdate: ToolDef = {
newProjectKey = null;
}
} else {
// scope === 'project' — schema refine guarantees `project` is set
// scope === 'project' — schema refine guarantees `project` is set.
const projectKey = parsed.data.project!;
const projectId = await resolveProjectId(ctx, projectKey);
if (!projectId) {
return err(`unknown project '${projectKey}'; call project.identify first`);
}
// Moving INTO a project requires write access there.
const allowedTarget = await canWriteProject(ctx.userId, ctx.groups, projectId);
if (!allowedTarget) {
return err(`no write access to project '${projectKey}'`);
}
if (existing.scope !== "project") {
update.scope = "project";
scopeChanged = true;
@@ -444,13 +659,27 @@ const memoryUpdate: ToolDef = {
}
}
const expectedVersion = parsed.data.version ?? existing.version;
const updated = await db
.update(memories)
.set(update)
.where(eq(memories.id, parsed.data.id))
.returning({ id: memories.id, updatedAt: memories.updatedAt });
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.version, expectedVersion),
),
)
.returning({
id: memories.id,
updatedAt: memories.updatedAt,
version: memories.version,
});
const auditFields = Object.keys(update).filter((k) => k !== "updatedAt");
if (!updated[0]) return err(CONCURRENT_EDIT_ERROR);
const auditFields = Object.keys(update).filter(
(k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy",
);
const auditPayload: Record<string, unknown> = { fields: auditFields };
if (scopeChanged || projectChanged) {
auditPayload.scope = {
@@ -496,17 +725,16 @@ const memorySearch: ToolDef = {
if (!parsed.success) return err(parsed.error.message);
const { query, scope, tags, limit } = parsed.data;
const projectId = parsed.data.project
? await resolveProjectId(ctx, parsed.data.project)
: null;
if (parsed.data.project && !projectId) {
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
const projectId = requestedKey ? await resolveProjectId(ctx, requestedKey) : null;
if (requestedKey && !projectId) {
return ok({ items: [], debug: { vec: 0, fts: 0, tag: 0 } }, "0 results (unknown project)");
}
const result = await searchMemories(
ctx.userId,
query,
{ scope, projectKey: parsed.data.project, tags },
{ scope, projectKey: requestedKey, tags, groupNames: ctx.groups },
limit,
);
@@ -522,6 +750,8 @@ const memorySearch: ToolDef = {
projectId: memories.projectId,
content: memories.content,
tags: memories.tags,
version: memories.version,
lastEditedBy: memories.lastEditedBy,
createdAt: memories.createdAt,
updatedAt: memories.updatedAt,
})
@@ -549,7 +779,7 @@ const memorySearch: ToolDef = {
const snippetPut: ToolDef = {
name: "snippet.put",
description:
"Save or update a named reusable artifact — a template, format, or checklist the user wants applied consistently. Call this when the user says 'remember this as my X template', 'save this format as Y', or 'use this checklist whenever I do Z'. Different from memory.write (which is for facts you'll later search): snippets are fetched by EXACT name, not searched, so the name is the contract — pick something stable and predictable (e.g. 'pr-description-format', 'commit-msg-rules', 'code-review-checklist'). Use scope='user' (default) for personal templates that apply everywhere; scope='project' for repo-specific variants (requires `project`, same key you used for project.identify). Re-calling with the same name+scope replaces the body in place — there is no separate update tool. Tags help browsing in the Web UI; they do NOT enable search.",
"Save or update a named reusable artifact — a template, format, or checklist the user wants applied consistently. Call this when the user says 'remember this as my X template', 'save this format as Y', or 'use this checklist whenever I do Z'. Different from memory.write (which is for facts you'll later search): snippets are fetched by EXACT name, not searched, so the name is the contract — pick something stable and predictable (e.g. 'pr-description-format', 'commit-msg-rules', 'code-review-checklist'). Use scope='user' (default) for personal templates that apply everywhere; scope='project' for repo-specific variants (requires `project`, same key you used for project.identify, defaulted from the X-Project-Key header). Re-calling with the same name+scope replaces the body in place — there is no separate update tool. In shared projects, anyone in a rw-access group can edit any project-scope snippet — pass `version` (returned by snippet.get / snippet.list) to detect concurrent edits and avoid clobbering. Tags help browsing in the Web UI; they do NOT enable search.",
inputSchema: {
type: "object",
properties: {
@@ -570,17 +800,24 @@ const snippetPut: ToolDef = {
type: "string",
enum: ["project", "user"],
description:
"'user' (default) = applies everywhere. 'project' = tied to one repo and requires `project`.",
"'user' (default) = applies everywhere. 'project' = tied to one repo and requires `project` (or X-Project-Key header).",
},
project: {
type: "string",
description: "Project key (required when scope='project').",
description:
"Project key. Required for scope='project'; defaults to the X-Project-Key request header if present.",
},
tags: {
type: "array",
items: { type: "string" },
description: "Optional tags for grouping in the Web UI.",
},
version: {
type: "integer",
minimum: 0,
description:
"Optimistic-locking token from snippet.get / snippet.list. Only consulted on the update path (i.e. when a row with this name+scope+project already exists).",
},
},
required: ["name", "body"],
},
@@ -590,48 +827,64 @@ const snippetPut: ToolDef = {
const parsed = SnippetPutInput.safeParse(withDefaultProject(args, ctx, "user"));
if (!parsed.success) return err(parsed.error.message);
if (parsed.data.scope === "project") {
const exists = await resolveProjectId(ctx, parsed.data.project!);
if (!exists) {
return err(
`unknown project '${parsed.data.project}'; call project.identify first`,
);
}
// Apply X-Project-Key default for scope=project.
const projectKey =
parsed.data.scope === "project"
? projectKeyOrDefault(ctx, parsed.data.project)
: undefined;
if (parsed.data.scope === "project" && !projectKey) {
return err("scope=project requires `project` (or X-Project-Key header)");
}
const { snippet, inserted } = await putSnippet(ctx.userId, {
name: parsed.data.name,
body: parsed.data.body,
description: parsed.data.description,
tags: parsed.data.tags,
scope: parsed.data.scope,
projectKey: parsed.data.project,
});
if (parsed.data.scope === "project") {
const exists = await resolveProjectId(ctx, projectKey!);
// It's OK for the project not to exist — putSnippet will create
// it owned by the caller. But if it DOES exist as a shared
// project, we need rw to write through it; the helper enforces
// that.
void exists;
}
await db.insert(auditLog).values({
userId: ctx.userId,
actor: "mcp",
action: inserted ? "snippet.put" : "snippet.update",
entityType: "snippet",
entityId: snippet.id,
payload: {
name: snippet.name,
scope: snippet.scope,
projectKey: snippet.projectKey,
tags: snippet.tags,
},
});
try {
const { snippet, inserted } = await putSnippet(ctx.userId, {
name: parsed.data.name,
body: parsed.data.body,
description: parsed.data.description,
tags: parsed.data.tags,
scope: parsed.data.scope,
projectKey,
groupNames: ctx.groups,
version: parsed.data.version,
});
return ok(
{
id: snippet.id,
name: snippet.name,
scope: snippet.scope,
project: snippet.projectKey,
inserted,
},
`${inserted ? "wrote" : "updated"} snippet '${snippet.name}' (${snippet.scope})`,
);
await db.insert(auditLog).values({
userId: ctx.userId,
actor: "mcp",
action: inserted ? "snippet.put" : "snippet.update",
entityType: "snippet",
entityId: snippet.id,
payload: {
name: snippet.name,
scope: snippet.scope,
projectKey: snippet.projectKey,
tags: snippet.tags,
},
});
return ok(
{
id: snippet.id,
name: snippet.name,
scope: snippet.scope,
project: snippet.projectKey,
version: snippet.version,
inserted,
},
`${inserted ? "wrote" : "updated"} snippet '${snippet.name}' (${snippet.scope})`,
);
} catch (e) {
return err(e instanceof Error ? e.message : "snippet.put failed");
}
},
};
@@ -661,10 +914,12 @@ const snippetGet: ToolDef = {
const parsed = SnippetGetInput.safeParse(withDefaultProject(args, ctx));
if (!parsed.success) return err(parsed.error.message);
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
const snippet = await getSnippet(ctx.userId, {
name: parsed.data.name,
scope: parsed.data.scope,
projectKey: parsed.data.project,
projectKey: requestedKey,
groupNames: ctx.groups,
});
if (!snippet) return err(`snippet '${parsed.data.name}' not found`);
@@ -678,6 +933,8 @@ const snippetGet: ToolDef = {
scope: snippet.scope,
project: snippet.projectKey,
tags: snippet.tags,
version: snippet.version,
lastEditedBy: snippet.lastEditedBy,
createdAt: snippet.createdAt,
updatedAt: snippet.updatedAt,
},
@@ -710,11 +967,13 @@ const snippetList: ToolDef = {
const parsed = SnippetListInput.safeParse(withDefaultProject(args, ctx));
if (!parsed.success) return err(parsed.error.message);
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
const rows = await listSnippets(ctx.userId, {
scope: parsed.data.scope,
projectKey: parsed.data.project,
projectKey: requestedKey,
tags: parsed.data.tags,
limit: parsed.data.limit,
groupNames: ctx.groups,
});
const items = rows.map((r) => ({
@@ -724,6 +983,8 @@ const snippetList: ToolDef = {
scope: r.scope,
project: r.projectKey,
tags: r.tags,
version: r.version,
lastEditedBy: r.lastEditedBy,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}));
@@ -749,30 +1010,36 @@ const snippetDelete: ToolDef = {
const parsed = SnippetDeleteInput.safeParse(withDefaultProject(args, ctx));
if (!parsed.success) return err(parsed.error.message);
const deleted = await softDeleteSnippet(ctx.userId, {
name: parsed.data.name,
scope: parsed.data.scope,
projectKey: parsed.data.project,
});
if (!deleted) return err(`snippet '${parsed.data.name}' not found`);
await db.insert(auditLog).values({
userId: ctx.userId,
actor: "mcp",
action: "snippet.delete",
entityType: "snippet",
entityId: deleted.id,
payload: {
const requestedKey = projectKeyOrDefault(ctx, parsed.data.project);
try {
const deleted = await softDeleteSnippet(ctx.userId, {
name: parsed.data.name,
scope: deleted.scope,
projectKey: deleted.projectKey,
},
});
scope: parsed.data.scope,
projectKey: requestedKey,
groupNames: ctx.groups,
});
if (!deleted) return err(`snippet '${parsed.data.name}' not found`);
return ok(
{ id: deleted.id, name: parsed.data.name, deleted: true },
`deleted snippet '${parsed.data.name}' (${deleted.scope})`,
);
await db.insert(auditLog).values({
userId: ctx.userId,
actor: "mcp",
action: "snippet.delete",
entityType: "snippet",
entityId: deleted.id,
payload: {
name: parsed.data.name,
scope: deleted.scope,
projectKey: deleted.projectKey,
},
});
return ok(
{ id: deleted.id, name: parsed.data.name, deleted: true },
`deleted snippet '${parsed.data.name}' (${deleted.scope})`,
);
} catch (e) {
return err(e instanceof Error ? e.message : "snippet.delete failed");
}
},
};
+64 -17
View File
@@ -1,7 +1,8 @@
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { db, pg } from "@/lib/db/client";
import { projects } from "@/lib/db/schema";
import { embedText } from "@/lib/embedder";
import { readableProjectIds } from "@/lib/access";
/**
* Shared search helper. Used by:
@@ -11,12 +12,23 @@ import { embedText } from "@/lib/embedder";
* Performs three candidate fetches in parallel — pgvector cosine, FTS
* ts_rank_cd, tag-set overlap — then fuses with Reciprocal Rank Fusion
* (k=60). Returns top-N with per-source rank info attached.
*
* Sharing model: a user can see memories they OWN (user_id = U) plus
* project-scope memories under any project that's been shared with one
* of their groups (any access — ro is enough to read). The three CTEs
* extend their WHERE clauses accordingly.
*/
export interface SearchFilters {
scope?: "project" | "user";
projectKey?: string;
tags?: string[];
/**
* Group names the requesting user is a member of. Drives shared-
* project visibility. An undefined value is treated as `[]` (no
* shared visibility) — pass through `UserContext.groups`.
*/
groupNames?: string[];
}
export interface SearchHit {
@@ -41,17 +53,38 @@ function toVectorLiteral(v: number[]): string {
return `[${v.join(",")}]`;
}
async function resolveProjectId(
async function resolveProjectIdForKey(
userId: string,
projectKey?: string,
groupNames: string[],
projectKey: string,
): Promise<string | null> {
if (!projectKey) return null;
const row = await db
// First check owned. Owned wins on key collision (matches
// project.identify's priority).
const owned = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.userId, userId), eq(projects.key, projectKey)))
.limit(1);
return row[0]?.id ?? null;
if (owned[0]) return owned[0].id;
if (groupNames.length === 0) return null;
// Then any shared project with that key. The user is allowed to read
// it; per-project authorization is enforced by the calling code's IN
// clause against `accessibleIds`.
const accessibleIds = await readableProjectIds(userId, groupNames);
if (accessibleIds.length === 0) return null;
const shared = await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.key, projectKey),
inArray(projects.id, accessibleIds),
),
)
.limit(1);
return shared[0]?.id ?? null;
}
export async function searchMemories(
@@ -60,8 +93,10 @@ export async function searchMemories(
filters: SearchFilters = {},
limit = 20,
): Promise<SearchResult> {
const { scope, projectKey, tags } = filters;
const projectId = projectKey ? await resolveProjectId(userId, projectKey) : null;
const { scope, projectKey, tags, groupNames = [] } = filters;
const projectId = projectKey
? await resolveProjectIdForKey(userId, groupNames, projectKey)
: null;
if (projectKey && !projectId) {
return { hits: [], debug: { vec: 0, fts: 0, tag: 0 } };
}
@@ -69,14 +104,28 @@ export async function searchMemories(
const queryVec = await embedText(query);
const vecLit = toVectorLiteral(queryVec);
// Build the user-visibility fragment once: rows the caller owns OR
// rows whose project_id is in the set of projects shared with this
// user's groups. When `projectId` is set we've already authorized
// that single project and can drop the fragment.
const accessibleProjectIds = projectId
? null
: await readableProjectIds(userId, groupNames);
// postgres-js's `${array}::uuid[]` interpolates as a Postgres array
// literal automatically. Empty array works: `= ANY('{}')` is false,
// which is the right behaviour for "no projects accessible".
const visibilityFragment = projectId
? pg`AND project_id = ${projectId}`
: pg`AND (user_id = ${userId} OR project_id = ANY(${accessibleProjectIds ?? []}::uuid[]))`;
const vecPromise = pg<{ id: string }[]>`
SELECT id
FROM memories
WHERE user_id = ${userId}
AND deleted_at IS NULL
WHERE deleted_at IS NULL
AND embedding IS NOT NULL
${scope ? pg`AND scope = ${scope}` : pg``}
${projectId ? pg`AND project_id = ${projectId}` : pg``}
${visibilityFragment}
ORDER BY embedding <=> ${vecLit}::vector ASC
LIMIT ${CANDIDATES}
`;
@@ -84,11 +133,10 @@ export async function searchMemories(
const ftsPromise = pg<{ id: string }[]>`
SELECT id
FROM memories, plainto_tsquery('english', ${query}) AS q
WHERE user_id = ${userId}
AND deleted_at IS NULL
WHERE deleted_at IS NULL
AND content_tsv @@ q
${scope ? pg`AND scope = ${scope}` : pg``}
${projectId ? pg`AND project_id = ${projectId}` : pg``}
${visibilityFragment}
ORDER BY ts_rank_cd(content_tsv, q) DESC
LIMIT ${CANDIDATES}
`;
@@ -98,11 +146,10 @@ export async function searchMemories(
? pg<{ id: string }[]>`
SELECT id
FROM memories
WHERE user_id = ${userId}
AND deleted_at IS NULL
WHERE deleted_at IS NULL
AND tags && ${tags}::text[]
${scope ? pg`AND scope = ${scope}` : pg``}
${projectId ? pg`AND project_id = ${projectId}` : pg``}
${visibilityFragment}
ORDER BY cardinality(
ARRAY(SELECT unnest(tags) INTERSECT SELECT unnest(${tags}::text[]))
) DESC
+135 -15
View File
@@ -7,12 +7,17 @@ import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { memories, projects, auditLog } from "@/lib/db/schema";
import { embedText } from "@/lib/embedder";
import { upsertProject } from "@/lib/projects";
import { resolveProjectId, upsertProject } from "@/lib/projects";
import {
MemoryWriteInput,
MemoryUpdateInput,
MemoryIdInput,
} from "@shared-memory/schemas";
import {
CONCURRENT_EDIT_ERROR,
canWriteProject,
getUserGroupNames,
} from "@/lib/access";
/**
* Server Actions for memory CRUD from the Web UI. Mirrors the MCP tools
@@ -20,6 +25,13 @@ import {
* indistinguishable from those made via Claude Code.
*
* `actor` is "web" in audit_log so we can tell the two paths apart later.
*
* Sharing: project-scope memories may live under projects shared with
* the user's groups. Reads include those projects; writes require the
* user to own the project or have an `rw` share. Cross-user concurrent
* edits use the `version` column for optimistic locking — if the stored
* version no longer matches what the form submitted, we surface
* `CONCURRENT_EDIT_ERROR` rather than clobber.
*/
async function requireUserId(): Promise<string> {
@@ -38,6 +50,7 @@ function parseTags(raw: FormDataEntryValue | null): string[] {
export async function createMemoryAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const payload = {
content: String(formData.get("content") ?? "").trim(),
@@ -53,7 +66,28 @@ export async function createMemoryAction(formData: FormData) {
let projectId: string | null = null;
if (parsed.data.scope === "project") {
if (!parsed.data.project) throw new Error("scope=project requires `project`");
projectId = await upsertProject(userId, parsed.data.project);
// Same priority as memory.update's reclassification path: prefer an
// owned project; otherwise check for a shared one we have rw on;
// otherwise auto-upsert as owner.
const owned = await resolveProjectId(userId, parsed.data.project);
if (owned) {
projectId = owned;
} else {
const sharedRow = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.key, parsed.data.project))
.limit(1);
if (sharedRow[0]) {
const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id);
if (!allowed) {
throw new Error(`no write access to project '${parsed.data.project}'`);
}
projectId = sharedRow[0].id;
} else {
projectId = await upsertProject(userId, parsed.data.project);
}
}
}
const embedding = await embedText(parsed.data.content);
@@ -67,6 +101,7 @@ export async function createMemoryAction(formData: FormData) {
content: parsed.data.content,
tags: parsed.data.tags ?? [],
embedding,
lastEditedBy: userId,
})
.returning({ id: memories.id });
@@ -89,10 +124,16 @@ export async function createMemoryAction(formData: FormData) {
export async function updateMemoryAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const id = String(formData.get("id") ?? "");
const rawScope = formData.get("scope");
const rawProject = (formData.get("project") as string | null)?.trim() || undefined;
const rawVersion = formData.get("version");
const versionNum =
typeof rawVersion === "string" && rawVersion.length > 0
? Number.parseInt(rawVersion, 10)
: undefined;
const payload = {
id,
content: ((formData.get("content") as string | null) ?? "").trim() || undefined,
@@ -102,12 +143,16 @@ export async function updateMemoryAction(formData: FormData) {
? (rawScope as "project" | "user")
: undefined,
project: rawProject,
version: Number.isFinite(versionNum) ? versionNum : undefined,
};
const parsed = MemoryUpdateInput.safeParse(payload);
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
// Fetch the row regardless of ownership — we may be editing a shared
// memory. Authorization is enforced below against the project, not
// by `user_id`.
const existingRows = await db
.select({
id: memories.id,
@@ -115,17 +160,32 @@ export async function updateMemoryAction(formData: FormData) {
scope: memories.scope,
projectId: memories.projectId,
projectKey: projects.key,
version: memories.version,
userId: memories.userId,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(
and(eq(memories.id, parsed.data.id), eq(memories.userId, userId), isNull(memories.deletedAt)),
)
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
const existing = existingRows[0];
if (!existing) throw new Error("not found");
const update: Record<string, unknown> = { updatedAt: new Date() };
// Authorize write. For user-scope memories, only the owner can edit.
// For project-scope memories, owner OR a group with rw access.
if (existing.scope === "user") {
if (existing.userId !== userId) throw new Error("not found");
} else if (existing.projectId) {
const allowed = await canWriteProject(userId, groupNames, existing.projectId);
if (!allowed) {
throw new Error("you don't have write access to this project");
}
}
const update: Record<string, unknown> = {
updatedAt: new Date(),
lastEditedBy: userId,
version: existing.version + 1,
};
if (parsed.data.tags !== undefined) update.tags = parsed.data.tags;
if (parsed.data.content !== undefined && parsed.data.content !== existing.content) {
update.content = parsed.data.content;
@@ -148,9 +208,34 @@ export async function updateMemoryAction(formData: FormData) {
newProjectKey = null;
}
} else {
// scope === 'project' — schema refine guarantees project is set
// scope === 'project' — schema refine guarantees project is set.
// Moving INTO a project requires write access there. Owners get
// a fresh project upsert; non-owners must target an existing one
// they have rw on.
const projectKey = parsed.data.project!;
const projectId = await upsertProject(userId, projectKey);
let projectId: string;
const existingId = await resolveProjectId(userId, projectKey);
if (existingId) {
projectId = existingId;
} else {
// Try a shared project with this key.
const sharedRow = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.key, projectKey))
.limit(1);
if (sharedRow[0]) {
const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id);
if (!allowed) {
throw new Error(`no write access to project '${projectKey}'`);
}
projectId = sharedRow[0].id;
} else {
// Auto-upsert as owner — user becomes the project owner of a
// brand-new private project.
projectId = await upsertProject(userId, projectKey);
}
}
if (existing.scope !== "project") {
update.scope = "project";
scopeChanged = true;
@@ -163,12 +248,27 @@ export async function updateMemoryAction(formData: FormData) {
}
}
await db
// Optimistic-locking guard. When `version` is supplied, the UPDATE
// matches on (id, version); a 0-row result means the caller's view
// is stale. When `version` is NOT supplied, we still match on the
// pre-fetched version to keep behaviour deterministic.
const expectedVersion = parsed.data.version ?? existing.version;
const updated = await db
.update(memories)
.set(update)
.where(and(eq(memories.id, parsed.data.id), eq(memories.userId, userId)));
.where(
and(
eq(memories.id, parsed.data.id),
eq(memories.version, expectedVersion),
),
)
.returning({ id: memories.id });
const auditFields = Object.keys(update).filter((k) => k !== "updatedAt");
if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR);
const auditFields = Object.keys(update).filter(
(k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy",
);
const auditPayload: Record<string, unknown> = { fields: auditFields };
if (scopeChanged || projectChanged) {
auditPayload.scope = {
@@ -197,16 +297,36 @@ export async function updateMemoryAction(formData: FormData) {
export async function deleteMemoryAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const id = String(formData.get("id") ?? "");
const parsed = MemoryIdInput.safeParse({ id });
if (!parsed.success) throw new Error(parsed.error.issues[0]!.message);
// Authorize delete: same rule as update — owner OR rw on the project.
const existing = await db
.select({
id: memories.id,
scope: memories.scope,
projectId: memories.projectId,
userId: memories.userId,
})
.from(memories)
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.limit(1);
const row = existing[0];
if (!row) throw new Error("not found");
if (row.scope === "user") {
if (row.userId !== userId) throw new Error("not found");
} else if (row.projectId) {
const allowed = await canWriteProject(userId, groupNames, row.projectId);
if (!allowed) throw new Error("you don't have write access to this project");
}
const updated = await db
.update(memories)
.set({ deletedAt: new Date() })
.where(
and(eq(memories.id, parsed.data.id), eq(memories.userId, userId), isNull(memories.deletedAt)),
)
.set({ deletedAt: new Date(), lastEditedBy: userId })
.where(and(eq(memories.id, parsed.data.id), isNull(memories.deletedAt)))
.returning({ id: memories.id });
if (!updated[0]) throw new Error("not found");
+256
View File
@@ -0,0 +1,256 @@
"use server";
import { revalidatePath } from "next/cache";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import {
auditLog,
groups,
projects,
projectShares,
userGroups,
} from "@/lib/db/schema";
import { MemoryAccess, ProjectKey } from "@shared-memory/schemas";
/**
* Server Actions for project-sharing controls.
*
* The sharing model:
* - Only the project owner can grant, change, or revoke shares.
* - The granter can only share with groups they themselves belong to.
* This prevents leaking projects to arbitrary group names from the
* OIDC IdP — you can only invite people you'd already see in the
* mirror.
* - All three actions audit-log with actor='web' so the timeline of
* access changes survives a future schema change.
*
* Inputs are read from FormData (typical Next.js Server Action surface)
* and validated with zod before any DB writes.
*/
async function requireUserId(): Promise<string> {
const session = await auth();
if (!session?.user?.id) throw new Error("not authenticated");
return session.user.id;
}
/**
* Look up a project this user owns, by key. Returns null if it doesn't
* exist or the caller isn't the owner. Owner-gating happens here rather
* than in every action.
*/
async function resolveOwnedProject(
userId: string,
projectKey: string,
): Promise<{ id: string; key: string } | null> {
const row = await db
.select({ id: projects.id, key: projects.key })
.from(projects)
.where(and(eq(projects.userId, userId), eq(projects.key, projectKey)))
.limit(1);
return row[0] ?? null;
}
/**
* Resolve a group by name AS LONG AS the caller is a member. This is
* the leak-prevention check described above: an owner can't bestow
* access on a group they themselves don't have visibility into.
*/
async function resolveGrantableGroup(
userId: string,
groupName: string,
): Promise<{ id: string; name: string } | null> {
const row = await db
.select({ id: groups.id, name: groups.name })
.from(groups)
.innerJoin(userGroups, eq(userGroups.groupId, groups.id))
.where(and(eq(groups.name, groupName), eq(userGroups.userId, userId)))
.limit(1);
return row[0] ?? null;
}
const AddShareInput = z.object({
projectKey: ProjectKey,
groupName: z.string().min(1).max(200),
access: MemoryAccess,
});
const UpdateShareInput = z.object({
projectKey: ProjectKey,
groupId: z.string().uuid(),
access: MemoryAccess,
});
const RemoveShareInput = z.object({
projectKey: ProjectKey,
groupId: z.string().uuid(),
});
export async function addProjectShareAction(formData: FormData) {
const userId = await requireUserId();
const parsed = AddShareInput.safeParse({
projectKey: String(formData.get("projectKey") ?? "").trim(),
groupName: String(formData.get("groupName") ?? "").trim(),
access: String(formData.get("access") ?? "ro"),
});
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
const project = await resolveOwnedProject(userId, parsed.data.projectKey);
if (!project) throw new Error("project not found or you don't own it");
const group = await resolveGrantableGroup(userId, parsed.data.groupName);
if (!group) {
throw new Error(
`you must be a member of group '${parsed.data.groupName}' to share with it`,
);
}
// Upsert: if a share already exists for (project, group), bump the
// access level. This makes the "Add share" form double as a sanity-
// safe re-grant path if a user accidentally re-adds the same group.
await db
.insert(projectShares)
.values({
projectId: project.id,
groupId: group.id,
access: parsed.data.access,
grantedBy: userId,
})
.onConflictDoUpdate({
target: [projectShares.projectId, projectShares.groupId],
set: {
access: parsed.data.access,
grantedBy: userId,
grantedAt: new Date(),
},
});
await db.insert(auditLog).values({
userId,
actor: "web",
action: "project.share.add",
entityType: "project",
entityId: project.id,
payload: {
projectKey: project.key,
groupName: group.name,
access: parsed.data.access,
},
});
revalidatePath(`/projects/${encodeURIComponent(project.key)}`);
}
export async function updateProjectShareAction(formData: FormData) {
const userId = await requireUserId();
const parsed = UpdateShareInput.safeParse({
projectKey: String(formData.get("projectKey") ?? "").trim(),
groupId: String(formData.get("groupId") ?? "").trim(),
access: String(formData.get("access") ?? "ro"),
});
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
const project = await resolveOwnedProject(userId, parsed.data.projectKey);
if (!project) throw new Error("project not found or you don't own it");
// The owner is allowed to flip any group's access — no membership
// check required (only the add path requires it; ownership is enough
// to twiddle an existing share). The row must exist.
const existing = await db
.select({ groupName: groups.name, access: projectShares.access })
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.where(
and(
eq(projectShares.projectId, project.id),
eq(projectShares.groupId, parsed.data.groupId),
),
)
.limit(1);
if (!existing[0]) throw new Error("share not found");
await db
.update(projectShares)
.set({ access: parsed.data.access, grantedBy: userId, grantedAt: new Date() })
.where(
and(
eq(projectShares.projectId, project.id),
eq(projectShares.groupId, parsed.data.groupId),
),
);
await db.insert(auditLog).values({
userId,
actor: "web",
action: "project.share.update",
entityType: "project",
entityId: project.id,
payload: {
projectKey: project.key,
groupName: existing[0].groupName,
access: { from: existing[0].access, to: parsed.data.access },
},
});
revalidatePath(`/projects/${encodeURIComponent(project.key)}`);
}
export async function removeProjectShareAction(formData: FormData) {
const userId = await requireUserId();
const parsed = RemoveShareInput.safeParse({
projectKey: String(formData.get("projectKey") ?? "").trim(),
groupId: String(formData.get("groupId") ?? "").trim(),
});
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
}
const project = await resolveOwnedProject(userId, parsed.data.projectKey);
if (!project) throw new Error("project not found or you don't own it");
const existing = await db
.select({ groupName: groups.name, access: projectShares.access })
.from(projectShares)
.innerJoin(groups, eq(groups.id, projectShares.groupId))
.where(
and(
eq(projectShares.projectId, project.id),
eq(projectShares.groupId, parsed.data.groupId),
),
)
.limit(1);
if (!existing[0]) throw new Error("share not found");
await db
.delete(projectShares)
.where(
and(
eq(projectShares.projectId, project.id),
eq(projectShares.groupId, parsed.data.groupId),
),
);
await db.insert(auditLog).values({
userId,
actor: "web",
action: "project.share.remove",
entityType: "project",
entityId: project.id,
payload: {
projectKey: project.key,
groupName: existing[0].groupName,
access: existing[0].access,
},
});
revalidatePath(`/projects/${encodeURIComponent(project.key)}`);
}
+18
View File
@@ -10,6 +10,7 @@ import {
SnippetDeleteInput,
} from "@shared-memory/schemas";
import { putSnippet, softDeleteSnippet } from "@/lib/snippets";
import { getUserGroupNames } from "@/lib/access";
/**
* Server Actions for snippet CRUD from the Web UI. Mirrors the MCP
@@ -17,6 +18,10 @@ import { putSnippet, softDeleteSnippet } from "@/lib/snippets";
* indistinguishable on the storage layer.
*
* `actor` is "web" in audit_log so we can tell the two paths apart later.
*
* Sharing: project-scope snippets under a shared project can be edited
* by any user with rw access via this path; the `putSnippet` helper
* enforces authorization and optimistic-locking concurrency control.
*/
async function requireUserId(): Promise<string> {
@@ -41,6 +46,7 @@ function targetUrl(scope: "project" | "user", name: string, projectKey: string |
export async function createSnippetAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const scope = (formData.get("scope") as "project" | "user") || "user";
const projectRaw = (formData.get("project") as string | null)?.trim();
@@ -65,6 +71,7 @@ export async function createSnippetAction(formData: FormData) {
tags: parsed.data.tags,
scope: parsed.data.scope,
projectKey: parsed.data.project,
groupNames,
});
await db.insert(auditLog).values({
@@ -87,11 +94,17 @@ export async function createSnippetAction(formData: FormData) {
export async function updateSnippetAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
// Edits keep the row's identity (scope + name + project unchanged) —
// body/description/tags are what changes. Treat as a put on the same key.
const scope = (formData.get("scope") as "project" | "user") || "user";
const projectRaw = (formData.get("project") as string | null)?.trim();
const rawVersion = formData.get("version");
const versionNum =
typeof rawVersion === "string" && rawVersion.length > 0
? Number.parseInt(rawVersion, 10)
: undefined;
const payload = {
name: String(formData.get("name") ?? "").trim(),
body: String(formData.get("body") ?? ""),
@@ -99,6 +112,7 @@ export async function updateSnippetAction(formData: FormData) {
tags: parseTags(formData.get("tags")),
scope,
project: scope === "project" ? projectRaw || undefined : undefined,
version: Number.isFinite(versionNum) ? versionNum : undefined,
};
const parsed = SnippetPutInput.safeParse(payload);
@@ -113,6 +127,8 @@ export async function updateSnippetAction(formData: FormData) {
tags: parsed.data.tags,
scope: parsed.data.scope,
projectKey: parsed.data.project,
groupNames,
version: parsed.data.version,
});
await db.insert(auditLog).values({
@@ -135,6 +151,7 @@ export async function updateSnippetAction(formData: FormData) {
export async function deleteSnippetAction(formData: FormData) {
const userId = await requireUserId();
const groupNames = await getUserGroupNames(userId);
const scope = formData.get("scope") as "project" | "user" | null;
const projectRaw = (formData.get("project") as string | null)?.trim();
@@ -153,6 +170,7 @@ export async function deleteSnippetAction(formData: FormData) {
name: parsed.data.name,
scope: parsed.data.scope,
projectKey: parsed.data.project,
groupNames,
});
if (!deleted) throw new Error("not found");
+157 -25
View File
@@ -1,7 +1,12 @@
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { snippets, projects } from "@/lib/db/schema";
import type { Snippet } from "@/lib/db/schema";
import {
CONCURRENT_EDIT_ERROR_SNIPPET,
canWriteProject,
readableProjectIds,
} from "@/lib/access";
/**
* Snippet data layer. Shared by the MCP tool handlers and the Web UI
@@ -17,8 +22,14 @@ import type { Snippet } from "@/lib/db/schema";
* `scope` (+ `project` when project-scoped). When `scope` is omitted on
* a get/delete, we prefer the project match (if `project` was supplied)
* else fall back to the user-scope row.
*
* Sharing extends visibility: for project-scope rows, anyone who has
* read access to the project sees the snippet; rw access is required
* for putSnippet's update path and softDeleteSnippet.
*/
export const CONCURRENT_EDIT_ERROR = CONCURRENT_EDIT_ERROR_SNIPPET;
export interface ResolvedScope {
scope: "project" | "user";
projectId: string | null;
@@ -33,6 +44,31 @@ async function resolveProjectId(userId: string, key: string): Promise<string | n
return row[0]?.id ?? null;
}
/**
* Resolve a project id by key, preferring an owned project, falling
* back to a shared project the user can read. Returns null if the key
* matches nothing visible. Used by snippet lookups (which need to find
* project-scope snippets under shared projects) — write authorization
* is enforced separately by the caller via `canWriteProject`.
*/
async function resolveVisibleProjectId(
userId: string,
groupNames: string[],
key: string,
): Promise<string | null> {
const owned = await resolveProjectId(userId, key);
if (owned) return owned;
if (groupNames.length === 0) return null;
const readableIds = await readableProjectIds(userId, groupNames);
if (readableIds.length === 0) return null;
const row = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.key, key), inArray(projects.id, readableIds)))
.limit(1);
return row[0]?.id ?? null;
}
async function upsertProject(userId: string, key: string): Promise<string> {
const existing = await resolveProjectId(userId, key);
if (existing) return existing;
@@ -47,14 +83,20 @@ export interface SnippetWithProjectKey extends Snippet {
projectKey: string | null;
}
/**
* Look up a snippet without enforcing ownership; visibility is restricted
* by the WHERE clause to "owner" or "in a project the user can read".
*
* For user-scope snippets there's no sharing concept — they're personal.
*/
async function findSnippet(
userId: string,
groupNames: string[],
name: string,
scope: "project" | "user",
projectId: string | null,
): Promise<SnippetWithProjectKey | null> {
const where = [
eq(snippets.userId, userId),
eq(snippets.name, name),
eq(snippets.scope, scope),
isNull(snippets.deletedAt),
@@ -62,7 +104,13 @@ async function findSnippet(
if (scope === "project") {
if (!projectId) return null;
where.push(eq(snippets.projectId, projectId));
// Project-scope snippet: visibility = owner OR project is readable.
// The caller has already resolved `projectId` via
// `resolveVisibleProjectId`, so we only need to filter to that
// project; any row under it is by definition visible to this user.
} else {
// User-scope snippet: strictly the caller's own row.
where.push(eq(snippets.userId, userId));
where.push(isNull(snippets.projectId));
}
const rows = await db
@@ -75,6 +123,8 @@ async function findSnippet(
body: snippets.body,
description: snippets.description,
tags: snippets.tags,
version: snippets.version,
lastEditedBy: snippets.lastEditedBy,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
deletedAt: snippets.deletedAt,
@@ -84,6 +134,9 @@ async function findSnippet(
.leftJoin(projects, eq(snippets.projectId, projects.id))
.where(and(...where))
.limit(1);
// groupNames is reserved for future per-group filtering paths; for
// now project-scope visibility is already encoded by `projectId`.
void groupNames;
return (rows[0] as SnippetWithProjectKey | undefined) ?? null;
}
@@ -91,6 +144,8 @@ async function findSnippet(
* Look up a single snippet by name. If `scope` is omitted, prefers a
* project match (when `projectKey` is provided) and falls back to the
* user-scope row. Returns null when nothing matches.
*
* `groupNames` widens project visibility to include shared projects.
*/
export async function getSnippet(
userId: string,
@@ -98,36 +153,48 @@ export async function getSnippet(
name: string;
scope?: "project" | "user";
projectKey?: string;
groupNames?: string[];
},
): Promise<SnippetWithProjectKey | null> {
const { name, scope, projectKey } = args;
const { name, scope, projectKey, groupNames = [] } = args;
if (scope === "project") {
if (!projectKey) return null;
const pid = await resolveProjectId(userId, projectKey);
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
if (!pid) return null;
return findSnippet(userId, name, "project", pid);
return findSnippet(userId, groupNames, name, "project", pid);
}
if (scope === "user") {
return findSnippet(userId, name, "user", null);
return findSnippet(userId, groupNames, name, "user", null);
}
// Scope unspecified: try project first if a key was given, then user.
if (projectKey) {
const pid = await resolveProjectId(userId, projectKey);
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
if (pid) {
const projectHit = await findSnippet(userId, name, "project", pid);
const projectHit = await findSnippet(userId, groupNames, name, "project", pid);
if (projectHit) return projectHit;
}
}
return findSnippet(userId, name, "user", null);
return findSnippet(userId, groupNames, name, "user", null);
}
/**
* Upsert a snippet keyed by (user, scope, project, name). If the row
* already exists (live, matching scope), it's replaced in place
* preserving its id. Returns the resulting row plus an `inserted` flag.
* Upsert a snippet keyed by (scope, project, name). If a live row with
* that key already exists, it's replaced in place — preserving its id
* but bumping `version` and recording `last_edited_by`. Returns the
* resulting row plus an `inserted` flag.
*
* Authorization:
* - user-scope: only the calling user can write.
* - project-scope: caller must own the project OR have rw access.
* When the project doesn't yet exist, it's auto-upserted with the
* caller as owner (matching memory-write semantics).
*
* Optimistic locking: pass `version` to require a CAS against the
* current row's version on the update path. A 0-row update surfaces
* `CONCURRENT_EDIT_ERROR_SNIPPET`. Ignored on insert.
*/
export async function putSnippet(
userId: string,
@@ -138,29 +205,56 @@ export async function putSnippet(
tags?: string[];
scope: "project" | "user";
projectKey?: string;
groupNames?: string[];
version?: number;
},
): Promise<{ snippet: SnippetWithProjectKey; inserted: boolean }> {
const { name, body, description, tags, scope, projectKey } = args;
const { name, body, description, tags, scope, projectKey, groupNames = [], version } = args;
let projectId: string | null = null;
if (scope === "project") {
if (!projectKey) throw new Error("scope=project requires projectKey");
projectId = await upsertProject(userId, projectKey);
// Prefer owned; if a shared project exists with this key, require
// rw to write through it; otherwise auto-upsert (caller-owned).
const owned = await resolveProjectId(userId, projectKey);
if (owned) {
projectId = owned;
} else {
const sharedRow = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.key, projectKey))
.limit(1);
if (sharedRow[0]) {
const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id);
if (!allowed) {
throw new Error(`no write access to project '${projectKey}'`);
}
projectId = sharedRow[0].id;
} else {
projectId = await upsertProject(userId, projectKey);
}
}
}
const existing = await findSnippet(userId, name, scope, projectId);
const existing = await findSnippet(userId, groupNames, name, scope, projectId);
if (existing) {
const updateValues: Record<string, unknown> = {
body,
tags: tags ?? existing.tags,
updatedAt: new Date(),
version: existing.version + 1,
lastEditedBy: userId,
};
if (description !== undefined) updateValues.description = description;
await db
const expectedVersion = version ?? existing.version;
const updated = await db
.update(snippets)
.set(updateValues)
.where(and(eq(snippets.id, existing.id), eq(snippets.userId, userId)));
const refreshed = await findSnippet(userId, name, scope, projectId);
.where(and(eq(snippets.id, existing.id), eq(snippets.version, expectedVersion)))
.returning({ id: snippets.id });
if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR_SNIPPET);
const refreshed = await findSnippet(userId, groupNames, name, scope, projectId);
return { snippet: refreshed!, inserted: false };
}
@@ -174,6 +268,7 @@ export async function putSnippet(
body,
description: description ?? null,
tags: tags ?? [],
lastEditedBy: userId,
})
.returning({ id: snippets.id });
@@ -187,6 +282,8 @@ export async function putSnippet(
body: snippets.body,
description: snippets.description,
tags: snippets.tags,
version: snippets.version,
lastEditedBy: snippets.lastEditedBy,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
deletedAt: snippets.deletedAt,
@@ -201,9 +298,13 @@ export async function putSnippet(
}
/**
* List live snippets for this user, newest first. Filters mirror
* memory.list. No pagination cursor yet — snippets are expected to be
* relatively low-volume; we cap at the requested limit.
* List live snippets visible to this user, newest first. Visibility:
* - user-scope rows owned by `userId`
* - project-scope rows under a project the user can read (owner or
* any group share)
*
* Filters mirror memory.list. No pagination cursor yet — snippets are
* expected to be relatively low-volume; we cap at the requested limit.
*/
export async function listSnippets(
userId: string,
@@ -212,15 +313,28 @@ export async function listSnippets(
projectKey?: string;
tags?: string[];
limit?: number;
groupNames?: string[];
} = {},
): Promise<SnippetWithProjectKey[]> {
const { scope, projectKey, tags, limit = 50 } = args;
const where = [eq(snippets.userId, userId), isNull(snippets.deletedAt)];
const { scope, projectKey, tags, limit = 50, groupNames = [] } = args;
// Visibility: own user-scope rows OR project-scope rows under a
// project the user can read.
const visibleProjectIds = await readableProjectIds(userId, groupNames);
const visibilityClause =
visibleProjectIds.length > 0
? or(
and(eq(snippets.userId, userId), isNull(snippets.projectId)),
inArray(snippets.projectId, visibleProjectIds),
)
: and(eq(snippets.userId, userId), isNull(snippets.projectId));
const where = [visibilityClause!, isNull(snippets.deletedAt)];
if (scope) where.push(eq(snippets.scope, scope));
if (projectKey) {
const pid = await resolveProjectId(userId, projectKey);
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
if (!pid) return [];
where.push(eq(snippets.projectId, pid));
}
@@ -239,6 +353,8 @@ export async function listSnippets(
body: snippets.body,
description: snippets.description,
tags: snippets.tags,
version: snippets.version,
lastEditedBy: snippets.lastEditedBy,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
deletedAt: snippets.deletedAt,
@@ -259,6 +375,9 @@ export async function listSnippets(
*
* If `scope` is omitted and `projectKey` is provided, deletes the
* project-scope row (if found) — falls back to user-scope otherwise.
*
* Authorization mirrors `putSnippet`: project-scope rows require rw on
* the project (or ownership); user-scope rows require ownership.
*/
export async function softDeleteSnippet(
userId: string,
@@ -266,14 +385,27 @@ export async function softDeleteSnippet(
name: string;
scope?: "project" | "user";
projectKey?: string;
groupNames?: string[];
},
): Promise<{ id: string; scope: "project" | "user"; projectKey: string | null } | null> {
const { groupNames = [] } = args;
const target = await getSnippet(userId, args);
if (!target) return null;
// Authorize the delete. For user-scope, only the owner can delete;
// `getSnippet` already filters to the user's own user-scope row, but
// we double-check defensively in case the same name exists across
// scopes and the caller passed scope=undefined.
if (target.scope === "user") {
if (target.userId !== userId) return null;
} else if (target.projectId) {
const allowed = await canWriteProject(userId, groupNames, target.projectId);
if (!allowed) throw new Error("you don't have write access to this project");
}
await db
.update(snippets)
.set({ deletedAt: new Date() })
.set({ deletedAt: new Date(), lastEditedBy: userId })
.where(eq(snippets.id, target.id));
return {