Files
PaperClipAI/server/src/services/resource-memberships.ts
T
DottaandPaperclip c111ee4cb3 feat(server): add per-user document stars (#9952)
## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies and their work.
> - Artifacts and documents are first-class outputs, but users need a
personal way to keep important documents easy to find.
> - Existing resource memberships already model per-user starred
projects and agents with company scoping and activity logging.
> - Documents lacked the equivalent membership model, route, and
artifact filtering behavior.
> - The shared membership contract also needs to remain safe for
existing UI project/agent mutation helpers when documents become a
recognized resource type.
> - This pull request extends the existing resource-membership system
with per-user document stars and a starred artifacts view.
> - The benefit is a company-scoped, idempotent server foundation for a
dedicated starred-documents experience without weakening authorization
or artifact filtering semantics.

## Linked Issues or Issue Description

### Problem / Motivation

Board users cannot star individual documents, and the company artifacts
API cannot return only the current user's starred documents.

### Proposed Solution

Add company/user-scoped document memberships, a board-only document star
route, document membership data in the shared contract, and a
`starred=true` artifacts filter.

### Alternatives Considered

A document column was rejected because stars are per-user; a separate
star API was rejected because projects and agents already use resource
memberships.

### Roadmap Alignment

This extends the existing Artifacts & Work Products roadmap area and
does not duplicate another open pull request found in the repository
search.

## What Changed

- Added the `document_memberships` schema and migration with
company/user/document uniqueness and starred ordering.
- Extended shared resource-membership and artifact-query contracts for
documents and `starred=true`.
- Added company-scoped document star/unstar service and board-only route
behavior with activity logging.
- Added starred document artifact filtering, including user-authored
documents, document kinds, cursor ordering, and incompatible-kind
handling.
- Preserved idempotency under concurrent star requests and synchronized
UI membership defaults/helpers with the expanded contract.
- Added focused shared, route, service, and UI regression coverage.

## Verification

- `pnpm exec vitest run packages/shared/src/resource-memberships.test.ts
server/src/__tests__/company-artifacts-service.test.ts
server/src/__tests__/resource-memberships-routes.test.ts`
- `pnpm exec vitest run ui/src/components/SidebarAgents.test.tsx
ui/src/components/SidebarProjects.test.tsx
ui/src/components/SidebarStarredProjects.test.tsx`
- `pnpm --filter @paperclipai/db check:migrations`
- `pnpm -r typecheck`
- `pnpm test:run`
- `pnpm build`

## Risks

- The migration adds a new membership table and non-concurrent indexes;
migration safety gates pass with the repository's established policy.
- The starred artifacts query intentionally returns only documents and
relaxes the normal agent-authored/system-kind predicates for documents
the current user explicitly starred.
- Document membership mutations remain board-user-only; agent callers
receive no document-star capability.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex CLI; runtime model ID and context-window size were not
exposed to this session. Reasoning, repository tool use, code execution,
and test execution were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-27 19:13:35 -05:00

545 lines
18 KiB
TypeScript

import { and, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agentMemberships,
agents,
documentMemberships,
documents,
projectMemberships,
projects,
} from "@paperclipai/db";
import type {
ResourceMembershipResourceType,
ResourceMembershipState,
ResourceMemberships,
ResourceMembershipUpdateResult,
} from "@paperclipai/shared";
import { forbidden, notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
type BoardActor = {
type: "board" | "agent" | "none";
userId?: string;
companyIds?: string[];
memberships?: Array<{
companyId: string;
membershipRole?: string | null;
status?: string;
}>;
isInstanceAdmin?: boolean;
source?: string;
};
type PolicyDecision = {
allowed: boolean;
reason?: string | null;
source?: string | null;
};
export type ResourceMembershipPolicyHook = (input: {
actor: BoardActor;
companyId: string;
userId: string;
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starred?: boolean;
}) => Promise<PolicyDecision> | PolicyDecision;
type ResourceMembershipServiceOptions = {
policyHook?: ResourceMembershipPolicyHook | null;
};
type MembershipChangeKind = ResourceMembershipState | "starred" | "unstarred";
type MembershipUpdateResult = ResourceMembershipUpdateResult & {
changed: boolean;
changeKind: MembershipChangeKind | null;
policySource: string;
};
function defaultJoinedMap<T extends { projectId?: string; agentId?: string; state: string }>(
rows: T[],
key: "projectId" | "agentId",
): Record<string, ResourceMembershipState> {
const result: Record<string, ResourceMembershipState> = {};
for (const row of rows) {
const id = row[key];
if (typeof id !== "string") continue;
result[id] = row.state === "left" ? "left" : "joined";
}
return result;
}
function starredAtMap<T extends { projectId?: string; agentId?: string; starredAt: Date | null }>(
rows: T[],
key: "projectId" | "agentId",
): Record<string, Date> {
const result: Record<string, Date> = {};
for (const row of rows) {
const id = row[key];
if (typeof id !== "string" || !row.starredAt) continue;
result[id] = row.starredAt;
}
return result;
}
function starredIds<T extends { projectId?: string; agentId?: string; starredAt: Date | null }>(
rows: T[],
key: "projectId" | "agentId",
): string[] {
return rows
.filter((row) => row.starredAt)
.sort((a, b) => b.starredAt!.getTime() - a.starredAt!.getTime())
.map((row) => row[key])
.filter((id): id is string => typeof id === "string");
}
function latestDate(...dates: Array<Date | null | undefined>): Date | null {
let latest: Date | null = null;
for (const date of dates) {
if (!date) continue;
if (!latest || date.getTime() > latest.getTime()) latest = date;
}
return latest;
}
function assertBoardSelfMembershipAccess(actor: BoardActor, companyId: string, userId: string) {
if (actor.type !== "board" || !actor.userId) {
throw forbidden("Board user access required");
}
if (actor.userId !== userId) {
throw forbidden("Users may only update their own resource memberships");
}
if (actor.source === "local_implicit" || actor.isInstanceAdmin) {
return;
}
const membership = actor.memberships?.find((item) => item.companyId === companyId);
if (!membership || membership.status !== "active") {
throw forbidden("User does not have active company access");
}
}
async function evaluatePolicy(
hook: ResourceMembershipPolicyHook | null | undefined,
input: Parameters<ResourceMembershipPolicyHook>[0],
): Promise<PolicyDecision> {
if (!hook) return { allowed: true, source: "oss_default" };
try {
const decision = await hook(input);
return {
allowed: decision.allowed === true,
reason: decision.reason ?? null,
source: decision.source ?? "policy_hook",
};
} catch (err) {
logger.warn(
{ err, companyId: input.companyId, resourceType: input.resourceType, resourceId: input.resourceId },
"resource membership policy hook failed closed",
);
return { allowed: false, reason: "policy_hook_failed", source: "policy_hook" };
}
}
export function resourceMembershipService(db: Db, options: ResourceMembershipServiceOptions = {}) {
const policyHook = options.policyHook ?? null;
async function assertMutationAllowed(input: {
actor: BoardActor;
companyId: string;
userId: string;
resourceType: ResourceMembershipResourceType;
resourceId: string;
state: ResourceMembershipState;
starred?: boolean;
}): Promise<PolicyDecision> {
assertBoardSelfMembershipAccess(input.actor, input.companyId, input.userId);
const decision = await evaluatePolicy(policyHook, input);
if (!decision.allowed) {
logger.warn(
{
companyId: input.companyId,
userId: input.userId,
resourceType: input.resourceType,
resourceId: input.resourceId,
reason: decision.reason ?? "denied",
source: decision.source ?? "policy_hook",
},
"resource membership mutation denied",
);
throw forbidden("Resource membership policy denied this request");
}
return decision;
}
return {
async listForUser(companyId: string, userId: string, actor: BoardActor): Promise<ResourceMemberships> {
assertBoardSelfMembershipAccess(actor, companyId, userId);
const [projectRows, agentRows, documentRows] = await Promise.all([
db
.select({
projectId: projectMemberships.projectId,
state: projectMemberships.state,
starredAt: projectMemberships.starredAt,
updatedAt: projectMemberships.updatedAt,
projectArchivedAt: projects.archivedAt,
})
.from(projectMemberships)
.innerJoin(projects, and(
eq(projects.id, projectMemberships.projectId),
eq(projects.companyId, projectMemberships.companyId),
))
.where(and(
eq(projectMemberships.companyId, companyId),
eq(projectMemberships.userId, userId),
)),
db
.select({
agentId: agentMemberships.agentId,
state: agentMemberships.state,
starredAt: agentMemberships.starredAt,
updatedAt: agentMemberships.updatedAt,
agentStatus: agents.status,
})
.from(agentMemberships)
.innerJoin(agents, and(
eq(agents.id, agentMemberships.agentId),
eq(agents.companyId, agentMemberships.companyId),
))
.where(and(
eq(agentMemberships.companyId, companyId),
eq(agentMemberships.userId, userId),
)),
db
.select({
documentId: documentMemberships.documentId,
starredAt: documentMemberships.starredAt,
updatedAt: documentMemberships.updatedAt,
})
.from(documentMemberships)
.innerJoin(documents, and(
eq(documents.id, documentMemberships.documentId),
eq(documents.companyId, documentMemberships.companyId),
))
.where(and(
eq(documentMemberships.companyId, companyId),
eq(documentMemberships.userId, userId),
)),
]);
const starEligibleProjectRows = projectRows.filter((row) => row.starredAt && !row.projectArchivedAt);
const starEligibleAgentRows = agentRows.filter((row) => row.starredAt && row.agentStatus !== "terminated");
const starredDocumentRows = documentRows
.filter((row): row is typeof row & { starredAt: Date } => row.starredAt !== null)
.sort((a, b) => b.starredAt.getTime() - a.starredAt.getTime());
return {
projectMemberships: defaultJoinedMap(projectRows, "projectId"),
agentMemberships: defaultJoinedMap(agentRows, "agentId"),
starredProjectIds: starredIds(starEligibleProjectRows, "projectId"),
starredAgentIds: starredIds(starEligibleAgentRows, "agentId"),
starredDocumentIds: starredDocumentRows.map((row) => row.documentId),
projectStarredAt: starredAtMap(starEligibleProjectRows, "projectId"),
agentStarredAt: starredAtMap(starEligibleAgentRows, "agentId"),
documentStarredAt: Object.fromEntries(
starredDocumentRows.map((row) => [row.documentId, row.starredAt.toISOString()]),
),
updatedAt: latestDate(
...projectRows.map((row) => row.updatedAt),
...agentRows.map((row) => row.updatedAt),
...documentRows.map((row) => row.updatedAt),
),
};
},
async updateProject(input: {
companyId: string;
userId: string;
projectId: string;
state?: ResourceMembershipState;
starred?: boolean;
actor: BoardActor;
}): Promise<MembershipUpdateResult> {
const project = await db.query.projects.findFirst({
where: and(
eq(projects.id, input.projectId),
eq(projects.companyId, input.companyId),
),
});
if (!project || project.archivedAt) throw notFound("Project not found");
const existing = await db.query.projectMemberships.findFirst({
where: and(
eq(projectMemberships.companyId, input.companyId),
eq(projectMemberships.userId, input.userId),
eq(projectMemberships.projectId, input.projectId),
),
});
const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined";
const previousStarredAt = existing?.starredAt ?? null;
const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState;
const nextStarredAt = nextState === "left"
? null
: input.starred === true
? previousStarredAt ?? new Date()
: input.starred === false
? null
: previousStarredAt;
const stateChanged = previousState !== nextState;
const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null);
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "project",
resourceId: input.projectId,
state: nextState,
starred: input.starred,
});
if (!stateChanged && !starredChanged) {
return {
resourceType: "project",
resourceId: input.projectId,
state: nextState,
starredAt: previousStarredAt,
updatedAt: existing?.updatedAt ?? new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
const now = new Date();
const [row] = await db
.insert(projectMemberships)
.values({
companyId: input.companyId,
projectId: input.projectId,
userId: input.userId,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [projectMemberships.companyId, projectMemberships.userId, projectMemberships.projectId],
set: {
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
},
})
.returning();
return {
resourceType: "project",
resourceId: input.projectId,
state: row?.state === "left" ? "left" : "joined",
starredAt: row?.starredAt ?? null,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: input.starred !== undefined && starredChanged
? input.starred ? "starred" : "unstarred"
: stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred",
policySource: decision.source ?? "oss_default",
};
},
async updateAgent(input: {
companyId: string;
userId: string;
agentId: string;
state?: ResourceMembershipState;
starred?: boolean;
actor: BoardActor;
}): Promise<MembershipUpdateResult> {
const agent = await db.query.agents.findFirst({
where: and(
eq(agents.id, input.agentId),
eq(agents.companyId, input.companyId),
),
});
if (!agent || agent.status === "terminated") throw notFound("Agent not found");
const existing = await db.query.agentMemberships.findFirst({
where: and(
eq(agentMemberships.companyId, input.companyId),
eq(agentMemberships.userId, input.userId),
eq(agentMemberships.agentId, input.agentId),
),
});
const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined";
const previousStarredAt = existing?.starredAt ?? null;
const nextState: ResourceMembershipState = input.starred === true ? "joined" : input.state ?? previousState;
const nextStarredAt = nextState === "left"
? null
: input.starred === true
? previousStarredAt ?? new Date()
: input.starred === false
? null
: previousStarredAt;
const stateChanged = previousState !== nextState;
const starredChanged = (previousStarredAt?.getTime() ?? null) !== (nextStarredAt?.getTime() ?? null);
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "agent",
resourceId: input.agentId,
state: nextState,
starred: input.starred,
});
if (!stateChanged && !starredChanged) {
return {
resourceType: "agent",
resourceId: input.agentId,
state: nextState,
starredAt: previousStarredAt,
updatedAt: existing?.updatedAt ?? new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
const now = new Date();
const [row] = await db
.insert(agentMemberships)
.values({
companyId: input.companyId,
agentId: input.agentId,
userId: input.userId,
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [agentMemberships.companyId, agentMemberships.userId, agentMemberships.agentId],
set: {
state: nextState,
starredAt: nextStarredAt,
updatedAt: now,
},
})
.returning();
return {
resourceType: "agent",
resourceId: input.agentId,
state: row?.state === "left" ? "left" : "joined",
starredAt: row?.starredAt ?? null,
updatedAt: row?.updatedAt ?? now,
changed: true,
changeKind: input.starred !== undefined && starredChanged
? input.starred ? "starred" : "unstarred"
: stateChanged ? nextState : nextStarredAt ? "starred" : "unstarred",
policySource: decision.source ?? "oss_default",
};
},
async updateDocument(input: {
companyId: string;
userId: string;
documentId: string;
starred: boolean;
actor: BoardActor;
}): Promise<MembershipUpdateResult> {
const document = await db.query.documents.findFirst({
where: eq(documents.id, input.documentId),
});
if (!document || document.companyId !== input.companyId) throw notFound("Document not found");
const decision = await assertMutationAllowed({
actor: input.actor,
companyId: input.companyId,
userId: input.userId,
resourceType: "document",
resourceId: input.documentId,
state: "joined",
starred: input.starred,
});
const existing = await db.query.documentMemberships.findFirst({
where: and(
eq(documentMemberships.companyId, input.companyId),
eq(documentMemberships.userId, input.userId),
eq(documentMemberships.documentId, input.documentId),
),
});
if (input.starred) {
if (existing?.starredAt) {
return {
resourceType: "document",
resourceId: input.documentId,
state: "joined",
starredAt: existing.starredAt,
updatedAt: existing.updatedAt,
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
const now = new Date();
const [row] = await db
.insert(documentMemberships)
.values({
companyId: input.companyId,
documentId: input.documentId,
userId: input.userId,
starredAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [documentMemberships.companyId, documentMemberships.userId, documentMemberships.documentId],
set: {
starredAt: sql`${documentMemberships.starredAt}`,
updatedAt: sql`${documentMemberships.updatedAt}`,
},
})
.returning({
starredAt: documentMemberships.starredAt,
updatedAt: documentMemberships.updatedAt,
inserted: sql<boolean>`xmax = 0`,
});
return {
resourceType: "document",
resourceId: input.documentId,
state: "joined",
starredAt: row?.starredAt ?? now,
updatedAt: row?.updatedAt ?? now,
changed: row?.inserted === true,
changeKind: row?.inserted === true ? "starred" : null,
policySource: decision.source ?? "oss_default",
};
}
if (!existing) {
return {
resourceType: "document",
resourceId: input.documentId,
state: "joined",
starredAt: null,
updatedAt: new Date(),
changed: false,
changeKind: null,
policySource: decision.source ?? "oss_default",
};
}
const [deleted] = await db
.delete(documentMemberships)
.where(eq(documentMemberships.id, existing.id))
.returning({ id: documentMemberships.id });
const changed = deleted !== undefined;
return {
resourceType: "document",
resourceId: input.documentId,
state: "joined",
starredAt: null,
updatedAt: new Date(),
changed,
changeKind: changed ? "unstarred" : null,
policySource: decision.source ?? "oss_default",
};
},
};
}