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:
@@ -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,
|
||||
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;
|
||||
|
||||
@@ -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
@@ -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 (1–64,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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
} 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
@@ -1,7 +1,12 @@
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { snippets, projects } from "@/lib/db/schema";
|
||||
import type { Snippet } from "@/lib/db/schema";
|
||||
import {
|
||||
CONCURRENT_EDIT_ERROR_SNIPPET,
|
||||
canWriteProject,
|
||||
readableProjectIds,
|
||||
} from "@/lib/access";
|
||||
|
||||
/**
|
||||
* Snippet data layer. Shared by the MCP tool handlers and the Web UI
|
||||
@@ -17,8 +22,14 @@ import type { Snippet } from "@/lib/db/schema";
|
||||
* `scope` (+ `project` when project-scoped). When `scope` is omitted on
|
||||
* a get/delete, we prefer the project match (if `project` was supplied)
|
||||
* else fall back to the user-scope row.
|
||||
*
|
||||
* Sharing extends visibility: for project-scope rows, anyone who has
|
||||
* read access to the project sees the snippet; rw access is required
|
||||
* for putSnippet's update path and softDeleteSnippet.
|
||||
*/
|
||||
|
||||
export const CONCURRENT_EDIT_ERROR = CONCURRENT_EDIT_ERROR_SNIPPET;
|
||||
|
||||
export interface ResolvedScope {
|
||||
scope: "project" | "user";
|
||||
projectId: string | null;
|
||||
@@ -33,6 +44,31 @@ async function resolveProjectId(userId: string, key: string): Promise<string | n
|
||||
return row[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a project id by key, preferring an owned project, falling
|
||||
* back to a shared project the user can read. Returns null if the key
|
||||
* matches nothing visible. Used by snippet lookups (which need to find
|
||||
* project-scope snippets under shared projects) — write authorization
|
||||
* is enforced separately by the caller via `canWriteProject`.
|
||||
*/
|
||||
async function resolveVisibleProjectId(
|
||||
userId: string,
|
||||
groupNames: string[],
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
const owned = await resolveProjectId(userId, key);
|
||||
if (owned) return owned;
|
||||
if (groupNames.length === 0) return null;
|
||||
const readableIds = await readableProjectIds(userId, groupNames);
|
||||
if (readableIds.length === 0) return null;
|
||||
const row = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.key, key), inArray(projects.id, readableIds)))
|
||||
.limit(1);
|
||||
return row[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function upsertProject(userId: string, key: string): Promise<string> {
|
||||
const existing = await resolveProjectId(userId, key);
|
||||
if (existing) return existing;
|
||||
@@ -47,14 +83,20 @@ export interface SnippetWithProjectKey extends Snippet {
|
||||
projectKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a snippet without enforcing ownership; visibility is restricted
|
||||
* by the WHERE clause to "owner" or "in a project the user can read".
|
||||
*
|
||||
* For user-scope snippets there's no sharing concept — they're personal.
|
||||
*/
|
||||
async function findSnippet(
|
||||
userId: string,
|
||||
groupNames: string[],
|
||||
name: string,
|
||||
scope: "project" | "user",
|
||||
projectId: string | null,
|
||||
): Promise<SnippetWithProjectKey | null> {
|
||||
const where = [
|
||||
eq(snippets.userId, userId),
|
||||
eq(snippets.name, name),
|
||||
eq(snippets.scope, scope),
|
||||
isNull(snippets.deletedAt),
|
||||
@@ -62,7 +104,13 @@ async function findSnippet(
|
||||
if (scope === "project") {
|
||||
if (!projectId) return null;
|
||||
where.push(eq(snippets.projectId, projectId));
|
||||
// Project-scope snippet: visibility = owner OR project is readable.
|
||||
// The caller has already resolved `projectId` via
|
||||
// `resolveVisibleProjectId`, so we only need to filter to that
|
||||
// project; any row under it is by definition visible to this user.
|
||||
} else {
|
||||
// User-scope snippet: strictly the caller's own row.
|
||||
where.push(eq(snippets.userId, userId));
|
||||
where.push(isNull(snippets.projectId));
|
||||
}
|
||||
const rows = await db
|
||||
@@ -75,6 +123,8 @@ async function findSnippet(
|
||||
body: snippets.body,
|
||||
description: snippets.description,
|
||||
tags: snippets.tags,
|
||||
version: snippets.version,
|
||||
lastEditedBy: snippets.lastEditedBy,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
deletedAt: snippets.deletedAt,
|
||||
@@ -84,6 +134,9 @@ async function findSnippet(
|
||||
.leftJoin(projects, eq(snippets.projectId, projects.id))
|
||||
.where(and(...where))
|
||||
.limit(1);
|
||||
// groupNames is reserved for future per-group filtering paths; for
|
||||
// now project-scope visibility is already encoded by `projectId`.
|
||||
void groupNames;
|
||||
return (rows[0] as SnippetWithProjectKey | undefined) ?? null;
|
||||
}
|
||||
|
||||
@@ -91,6 +144,8 @@ async function findSnippet(
|
||||
* Look up a single snippet by name. If `scope` is omitted, prefers a
|
||||
* project match (when `projectKey` is provided) and falls back to the
|
||||
* user-scope row. Returns null when nothing matches.
|
||||
*
|
||||
* `groupNames` widens project visibility to include shared projects.
|
||||
*/
|
||||
export async function getSnippet(
|
||||
userId: string,
|
||||
@@ -98,36 +153,48 @@ export async function getSnippet(
|
||||
name: string;
|
||||
scope?: "project" | "user";
|
||||
projectKey?: string;
|
||||
groupNames?: string[];
|
||||
},
|
||||
): Promise<SnippetWithProjectKey | null> {
|
||||
const { name, scope, projectKey } = args;
|
||||
const { name, scope, projectKey, groupNames = [] } = args;
|
||||
|
||||
if (scope === "project") {
|
||||
if (!projectKey) return null;
|
||||
const pid = await resolveProjectId(userId, projectKey);
|
||||
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
|
||||
if (!pid) return null;
|
||||
return findSnippet(userId, name, "project", pid);
|
||||
return findSnippet(userId, groupNames, name, "project", pid);
|
||||
}
|
||||
|
||||
if (scope === "user") {
|
||||
return findSnippet(userId, name, "user", null);
|
||||
return findSnippet(userId, groupNames, name, "user", null);
|
||||
}
|
||||
|
||||
// Scope unspecified: try project first if a key was given, then user.
|
||||
if (projectKey) {
|
||||
const pid = await resolveProjectId(userId, projectKey);
|
||||
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
|
||||
if (pid) {
|
||||
const projectHit = await findSnippet(userId, name, "project", pid);
|
||||
const projectHit = await findSnippet(userId, groupNames, name, "project", pid);
|
||||
if (projectHit) return projectHit;
|
||||
}
|
||||
}
|
||||
return findSnippet(userId, name, "user", null);
|
||||
return findSnippet(userId, groupNames, name, "user", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a snippet keyed by (user, scope, project, name). If the row
|
||||
* already exists (live, matching scope), it's replaced in place
|
||||
* preserving its id. Returns the resulting row plus an `inserted` flag.
|
||||
* Upsert a snippet keyed by (scope, project, name). If a live row with
|
||||
* that key already exists, it's replaced in place — preserving its id
|
||||
* but bumping `version` and recording `last_edited_by`. Returns the
|
||||
* resulting row plus an `inserted` flag.
|
||||
*
|
||||
* Authorization:
|
||||
* - user-scope: only the calling user can write.
|
||||
* - project-scope: caller must own the project OR have rw access.
|
||||
* When the project doesn't yet exist, it's auto-upserted with the
|
||||
* caller as owner (matching memory-write semantics).
|
||||
*
|
||||
* Optimistic locking: pass `version` to require a CAS against the
|
||||
* current row's version on the update path. A 0-row update surfaces
|
||||
* `CONCURRENT_EDIT_ERROR_SNIPPET`. Ignored on insert.
|
||||
*/
|
||||
export async function putSnippet(
|
||||
userId: string,
|
||||
@@ -138,29 +205,56 @@ export async function putSnippet(
|
||||
tags?: string[];
|
||||
scope: "project" | "user";
|
||||
projectKey?: string;
|
||||
groupNames?: string[];
|
||||
version?: number;
|
||||
},
|
||||
): Promise<{ snippet: SnippetWithProjectKey; inserted: boolean }> {
|
||||
const { name, body, description, tags, scope, projectKey } = args;
|
||||
const { name, body, description, tags, scope, projectKey, groupNames = [], version } = args;
|
||||
|
||||
let projectId: string | null = null;
|
||||
if (scope === "project") {
|
||||
if (!projectKey) throw new Error("scope=project requires projectKey");
|
||||
projectId = await upsertProject(userId, projectKey);
|
||||
// Prefer owned; if a shared project exists with this key, require
|
||||
// rw to write through it; otherwise auto-upsert (caller-owned).
|
||||
const owned = await resolveProjectId(userId, projectKey);
|
||||
if (owned) {
|
||||
projectId = owned;
|
||||
} else {
|
||||
const sharedRow = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.key, projectKey))
|
||||
.limit(1);
|
||||
if (sharedRow[0]) {
|
||||
const allowed = await canWriteProject(userId, groupNames, sharedRow[0].id);
|
||||
if (!allowed) {
|
||||
throw new Error(`no write access to project '${projectKey}'`);
|
||||
}
|
||||
projectId = sharedRow[0].id;
|
||||
} else {
|
||||
projectId = await upsertProject(userId, projectKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await findSnippet(userId, name, scope, projectId);
|
||||
const existing = await findSnippet(userId, groupNames, name, scope, projectId);
|
||||
if (existing) {
|
||||
const updateValues: Record<string, unknown> = {
|
||||
body,
|
||||
tags: tags ?? existing.tags,
|
||||
updatedAt: new Date(),
|
||||
version: existing.version + 1,
|
||||
lastEditedBy: userId,
|
||||
};
|
||||
if (description !== undefined) updateValues.description = description;
|
||||
await db
|
||||
const expectedVersion = version ?? existing.version;
|
||||
const updated = await db
|
||||
.update(snippets)
|
||||
.set(updateValues)
|
||||
.where(and(eq(snippets.id, existing.id), eq(snippets.userId, userId)));
|
||||
const refreshed = await findSnippet(userId, name, scope, projectId);
|
||||
.where(and(eq(snippets.id, existing.id), eq(snippets.version, expectedVersion)))
|
||||
.returning({ id: snippets.id });
|
||||
if (!updated[0]) throw new Error(CONCURRENT_EDIT_ERROR_SNIPPET);
|
||||
const refreshed = await findSnippet(userId, groupNames, name, scope, projectId);
|
||||
return { snippet: refreshed!, inserted: false };
|
||||
}
|
||||
|
||||
@@ -174,6 +268,7 @@ export async function putSnippet(
|
||||
body,
|
||||
description: description ?? null,
|
||||
tags: tags ?? [],
|
||||
lastEditedBy: userId,
|
||||
})
|
||||
.returning({ id: snippets.id });
|
||||
|
||||
@@ -187,6 +282,8 @@ export async function putSnippet(
|
||||
body: snippets.body,
|
||||
description: snippets.description,
|
||||
tags: snippets.tags,
|
||||
version: snippets.version,
|
||||
lastEditedBy: snippets.lastEditedBy,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
deletedAt: snippets.deletedAt,
|
||||
@@ -201,9 +298,13 @@ export async function putSnippet(
|
||||
}
|
||||
|
||||
/**
|
||||
* List live snippets for this user, newest first. Filters mirror
|
||||
* memory.list. No pagination cursor yet — snippets are expected to be
|
||||
* relatively low-volume; we cap at the requested limit.
|
||||
* List live snippets visible to this user, newest first. Visibility:
|
||||
* - user-scope rows owned by `userId`
|
||||
* - project-scope rows under a project the user can read (owner or
|
||||
* any group share)
|
||||
*
|
||||
* Filters mirror memory.list. No pagination cursor yet — snippets are
|
||||
* expected to be relatively low-volume; we cap at the requested limit.
|
||||
*/
|
||||
export async function listSnippets(
|
||||
userId: string,
|
||||
@@ -212,15 +313,28 @@ export async function listSnippets(
|
||||
projectKey?: string;
|
||||
tags?: string[];
|
||||
limit?: number;
|
||||
groupNames?: string[];
|
||||
} = {},
|
||||
): Promise<SnippetWithProjectKey[]> {
|
||||
const { scope, projectKey, tags, limit = 50 } = args;
|
||||
const where = [eq(snippets.userId, userId), isNull(snippets.deletedAt)];
|
||||
const { scope, projectKey, tags, limit = 50, groupNames = [] } = args;
|
||||
|
||||
// Visibility: own user-scope rows OR project-scope rows under a
|
||||
// project the user can read.
|
||||
const visibleProjectIds = await readableProjectIds(userId, groupNames);
|
||||
const visibilityClause =
|
||||
visibleProjectIds.length > 0
|
||||
? or(
|
||||
and(eq(snippets.userId, userId), isNull(snippets.projectId)),
|
||||
inArray(snippets.projectId, visibleProjectIds),
|
||||
)
|
||||
: and(eq(snippets.userId, userId), isNull(snippets.projectId));
|
||||
|
||||
const where = [visibilityClause!, isNull(snippets.deletedAt)];
|
||||
|
||||
if (scope) where.push(eq(snippets.scope, scope));
|
||||
|
||||
if (projectKey) {
|
||||
const pid = await resolveProjectId(userId, projectKey);
|
||||
const pid = await resolveVisibleProjectId(userId, groupNames, projectKey);
|
||||
if (!pid) return [];
|
||||
where.push(eq(snippets.projectId, pid));
|
||||
}
|
||||
@@ -239,6 +353,8 @@ export async function listSnippets(
|
||||
body: snippets.body,
|
||||
description: snippets.description,
|
||||
tags: snippets.tags,
|
||||
version: snippets.version,
|
||||
lastEditedBy: snippets.lastEditedBy,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
deletedAt: snippets.deletedAt,
|
||||
@@ -259,6 +375,9 @@ export async function listSnippets(
|
||||
*
|
||||
* If `scope` is omitted and `projectKey` is provided, deletes the
|
||||
* project-scope row (if found) — falls back to user-scope otherwise.
|
||||
*
|
||||
* Authorization mirrors `putSnippet`: project-scope rows require rw on
|
||||
* the project (or ownership); user-scope rows require ownership.
|
||||
*/
|
||||
export async function softDeleteSnippet(
|
||||
userId: string,
|
||||
@@ -266,14 +385,27 @@ export async function softDeleteSnippet(
|
||||
name: string;
|
||||
scope?: "project" | "user";
|
||||
projectKey?: string;
|
||||
groupNames?: string[];
|
||||
},
|
||||
): Promise<{ id: string; scope: "project" | "user"; projectKey: string | null } | null> {
|
||||
const { groupNames = [] } = args;
|
||||
const target = await getSnippet(userId, args);
|
||||
if (!target) return null;
|
||||
|
||||
// Authorize the delete. For user-scope, only the owner can delete;
|
||||
// `getSnippet` already filters to the user's own user-scope row, but
|
||||
// we double-check defensively in case the same name exists across
|
||||
// scopes and the caller passed scope=undefined.
|
||||
if (target.scope === "user") {
|
||||
if (target.userId !== userId) return null;
|
||||
} else if (target.projectId) {
|
||||
const allowed = await canWriteProject(userId, groupNames, target.projectId);
|
||||
if (!allowed) throw new Error("you don't have write access to this project");
|
||||
}
|
||||
|
||||
await db
|
||||
.update(snippets)
|
||||
.set({ deletedAt: new Date() })
|
||||
.set({ deletedAt: new Date(), lastEditedBy: userId })
|
||||
.where(eq(snippets.id, target.id));
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user