feat: implement multi-user access and invite flows (#3784)

## Thinking Path

> - Paperclip is the control plane for autonomous AI companies.
> - V1 needs to stay local-first while also supporting shared,
authenticated deployments.
> - Human operators need real identities, company membership, invite
flows, profile surfaces, and company-scoped access controls.
> - Agents and operators also need the existing issue, inbox, workspace,
approval, and plugin flows to keep working under those authenticated
boundaries.
> - This branch accumulated the multi-user implementation, follow-up QA
fixes, workspace/runtime refinements, invite UX improvements,
release-branch conflict resolution, and review hardening.
> - This pull request consolidates that branch onto the current `master`
branch as a single reviewable PR.
> - The benefit is a complete multi-user implementation path with tests
and docs carried forward without dropping existing branch work.

## What Changed

- Added authenticated human-user access surfaces: auth/session routes,
company user directory, profile settings, company access/member
management, join requests, and invite management.
- Added invite creation, invite landing, onboarding, logo/branding,
invite grants, deduped join requests, and authenticated multi-user E2E
coverage.
- Tightened company-scoped and instance-admin authorization across
board, plugin, adapter, access, issue, and workspace routes.
- Added profile-image URL validation hardening, avatar preservation on
name-only profile updates, and join-request uniqueness migration cleanup
for pending human requests.
- Added an atomic member role/status/grants update path so Company
Access saves no longer leave partially updated permissions.
- Improved issue chat, inbox, assignee identity rendering,
sidebar/account/company navigation, workspace routing, and execution
workspace reuse behavior for multi-user operation.
- Added and updated server/UI tests covering auth, invites, membership,
issue workspace inheritance, plugin authz, inbox/chat behavior, and
multi-user flows.
- Merged current `public-gh/master` into this branch, resolved all
conflicts, and verified no `pnpm-lock.yaml` change is included in this
PR diff.

## Verification

- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts
ui/src/components/IssueChatThread.test.tsx ui/src/pages/Inbox.test.tsx`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/plugin-routes-authz.test.ts`
- `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/workspace-runtime-service-authz.test.ts
server/src/__tests__/access-validators.test.ts`
- `pnpm exec vitest run
server/src/__tests__/authz-company-access.test.ts
server/src/__tests__/routines-routes.test.ts
server/src/__tests__/sidebar-preferences-routes.test.ts
server/src/__tests__/approval-routes-idempotency.test.ts
server/src/__tests__/openclaw-invite-prompt-route.test.ts
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
server/src/__tests__/routines-e2e.test.ts`
- `pnpm exec vitest run server/src/__tests__/auth-routes.test.ts
ui/src/pages/CompanyAccess.test.tsx`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/db typecheck && pnpm --filter @paperclipai/server
typecheck`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm db:generate`
- `npx playwright test --config tests/e2e/playwright.config.ts --list`
- Confirmed branch has no uncommitted changes and is `0` commits behind
`public-gh/master` before PR creation.
- Confirmed no `pnpm-lock.yaml` change is staged or present in the PR
diff.

## Risks

- High review surface area: this PR contains the accumulated multi-user
branch plus follow-up fixes, so reviewers should focus especially on
company-boundary enforcement and authenticated-vs-local deployment
behavior.
- UI behavior changed across invites, inbox, issue chat, access
settings, and sidebar navigation; no browser screenshots are included in
this branch-consolidation PR.
- Plugin install, upgrade, and lifecycle/config mutations now require
instance-admin access, which is intentional but may change expectations
for non-admin board users.
- A join-request dedupe migration rejects duplicate pending human
requests before creating unique indexes; deployments with unusual
historical duplicates should review the migration behavior.
- Company member role/status/grant saves now use a new combined
endpoint; older separate endpoints remain for compatibility.
- Full production build was not run locally in this heartbeat; CI should
cover the full matrix.

## Model Used

- OpenAI Codex coding agent, GPT-5-based model, CLI/tool-use
environment. Exact deployed model identifier and context window were not
exposed by the runtime.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Note on screenshots: this is a branch-consolidation PR for an
already-developed multi-user branch, and no browser screenshots were
captured during this heartbeat.

---------

Co-authored-by: dotta <dotta@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-04-17 09:44:19 -05:00 committed by GitHub
parent e93e418cbf
commit b9a80dcf22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
150 changed files with 26872 additions and 1289 deletions

View file

@ -0,0 +1,99 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
companies,
companyMemberships,
createDb,
principalPermissionGrants,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { accessService } from "../services/access.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function createCompanyWithOwner(db: ReturnType<typeof createDb>) {
const company = await db
.insert(companies)
.values({
name: `Access Service ${randomUUID()}`,
issuePrefix: `AS${randomUUID().slice(0, 6).toUpperCase()}`,
})
.returning()
.then((rows) => rows[0]!);
const owner = await db
.insert(companyMemberships)
.values({
companyId: company.id,
principalType: "user",
principalId: `owner-${randomUUID()}`,
status: "active",
membershipRole: "owner",
})
.returning()
.then((rows) => rows[0]!);
return { company, owner };
}
describeEmbeddedPostgres("access service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-service-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("rejects combined access updates that would demote the last active owner", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const access = accessService(db);
await expect(
access.updateMemberAndPermissions(
company.id,
owner.id,
{ membershipRole: "admin", grants: [] },
"admin-user",
),
).rejects.toThrow("Cannot remove the last active owner");
const unchanged = await db
.select()
.from(companyMemberships)
.where(eq(companyMemberships.id, owner.id))
.then((rows) => rows[0]!);
expect(unchanged.membershipRole).toBe("owner");
});
it("rejects role-only updates that would suspend the last active owner", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const access = accessService(db);
await expect(
access.updateMember(company.id, owner.id, { status: "suspended" }),
).rejects.toThrow("Cannot remove the last active owner");
const unchanged = await db
.select()
.from(companyMemberships)
.where(eq(companyMemberships.id, owner.id))
.then((rows) => rows[0]!);
expect(unchanged.status).toBe("active");
});
});

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
updateCompanyMemberWithPermissionsSchema,
updateCurrentUserProfileSchema,
} from "@paperclipai/shared";
describe("access validators", () => {
it("accepts HTTP(S) and Paperclip asset image URLs", () => {
expect(updateCurrentUserProfileSchema.safeParse({
name: "Ada Lovelace",
image: "https://example.com/avatar.png",
}).success).toBe(true);
expect(updateCurrentUserProfileSchema.safeParse({
name: "Ada Lovelace",
image: "/api/assets/avatar/content",
}).success).toBe(true);
});
it("rejects data URI profile images", () => {
expect(updateCurrentUserProfileSchema.safeParse({
name: "Ada Lovelace",
image: "data:image/png;base64,AAAA",
}).success).toBe(false);
});
it("defaults omitted combined member grants to an empty list", () => {
const result = updateCompanyMemberWithPermissionsSchema.parse({
membershipRole: "operator",
});
expect(result.grants).toEqual([]);
});
});

View file

@ -31,7 +31,7 @@ let setOverridePaused: typeof import("../adapters/registry.js").setOverridePause
let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes;
let errorHandler: typeof import("../middleware/index.js").errorHandler;
function createApp() {
function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -41,6 +41,7 @@ function createApp() {
companyIds: [],
source: "local_implicit",
isInstanceAdmin: false,
...actorOverrides,
};
next();
});
@ -166,4 +167,18 @@ describe("adapter routes", () => {
fields: [{ key: "mode" }],
});
});
it("rejects signed-in users without org access", async () => {
const app = createApp({
userId: "outsider-1",
source: "session",
companyIds: [],
memberships: [],
isInstanceAdmin: false,
});
const res = await request(app).get("/api/adapters/claude_local/config-schema");
expect(res.status, JSON.stringify(res.body)).toBe(403);
});
});

View file

@ -395,14 +395,6 @@ describe("agent skill routes", () => {
});
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
adapterType: "claude_local",
}),
{ "AGENTS.md": "You are QA." },
{ entryFile: "AGENTS.md", replaceExisting: false },
);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({

View file

@ -0,0 +1,170 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
function createSelectChain(rows: unknown[]) {
return {
from() {
return {
where() {
return Promise.resolve(rows);
},
};
},
};
}
function createUpdateChain(row: unknown) {
return {
set(values: unknown) {
return {
where() {
return {
returning() {
return Promise.resolve([{ ...(row as Record<string, unknown>), ...(values as Record<string, unknown>) }]);
},
};
},
};
},
};
}
function createDb(row: Record<string, unknown>) {
return {
select: vi.fn(() => createSelectChain([row])),
update: vi.fn(() => createUpdateChain(row)),
} as any;
}
async function createApp(actor: Express.Request["actor"], row: Record<string, unknown>) {
const [{ authRoutes }, { errorHandler }] = await Promise.all([
import("../routes/auth.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api/auth", authRoutes(createDb(row)));
app.use(errorHandler);
return app;
}
describe("auth routes", () => {
const baseUser = {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: "https://example.com/jane.png",
};
beforeEach(() => {
vi.resetModules();
});
it("returns the persisted user profile in the session payload", async () => {
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "session",
},
baseUser,
);
const res = await request(app).get("/api/auth/get-session");
expect(res.status).toBe(200);
expect(res.body).toEqual({
session: {
id: "paperclip:session:user-1",
userId: "user-1",
},
user: baseUser,
});
});
it("updates the signed-in profile", async () => {
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "local_implicit",
},
baseUser,
);
const res = await request(app)
.patch("/api/auth/profile")
.send({ name: "Board Operator", image: "" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "user-1",
name: "Board Operator",
email: "jane@example.com",
image: null,
});
});
it("preserves the existing avatar when updating only the profile name", async () => {
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "local_implicit",
},
baseUser,
);
const res = await request(app)
.patch("/api/auth/profile")
.send({ name: "Board Operator" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "user-1",
name: "Board Operator",
email: "jane@example.com",
image: "https://example.com/jane.png",
});
});
it("accepts Paperclip asset paths for avatars", async () => {
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "session",
},
baseUser,
);
const res = await request(app)
.patch("/api/auth/profile")
.send({ name: "Jane Example", image: "/api/assets/asset-1/content" });
expect(res.status).toBe(200);
expect(res.body.image).toBe("/api/assets/asset-1/content");
});
it("rejects invalid avatar image references", async () => {
const app = await createApp(
{
type: "board",
userId: "user-1",
source: "session",
},
baseUser,
);
const res = await request(app)
.patch("/api/auth/profile")
.send({ name: "Jane Example", image: "not-a-url" });
expect(res.status).toBe(400);
});
});

View file

@ -0,0 +1,61 @@
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { actorMiddleware } from "../middleware/auth.js";
function createSelectChain(rows: unknown[]) {
return {
from() {
return {
where() {
return Promise.resolve(rows);
},
};
},
};
}
function createDb() {
return {
select: vi
.fn()
.mockImplementationOnce(() => createSelectChain([]))
.mockImplementationOnce(() => createSelectChain([])),
} as any;
}
describe("actorMiddleware authenticated session profile", () => {
it("preserves the signed-in user name and email on the board actor", async () => {
const app = express();
app.use(
actorMiddleware(createDb(), {
deploymentMode: "authenticated",
resolveSession: async () => ({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "User One",
email: "user@example.com",
},
}),
}),
);
app.get("/actor", (req, res) => {
res.json(req.actor);
});
const res = await request(app).get("/actor");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
type: "board",
userId: "user-1",
userName: "User One",
userEmail: "user@example.com",
source: "session",
companyIds: [],
memberships: [],
isInstanceAdmin: false,
});
});
});

View file

@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import { assertBoardOrgAccess, assertCompanyAccess, hasBoardOrgAccess } from "../routes/authz.js";
function makeReq(input: {
method?: string;
actor: Express.Request["actor"];
}) {
return {
method: input.method ?? "GET",
actor: input.actor,
} as Express.Request;
}
describe("assertCompanyAccess", () => {
it("allows viewer memberships to read", () => {
const req = makeReq({
method: "GET",
actor: {
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-1"],
memberships: [
{ companyId: "company-1", membershipRole: "viewer", status: "active" },
],
},
});
expect(() => assertCompanyAccess(req, "company-1")).not.toThrow();
});
it("rejects viewer memberships for writes", () => {
const req = makeReq({
method: "PATCH",
actor: {
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-1"],
memberships: [
{ companyId: "company-1", membershipRole: "viewer", status: "active" },
],
},
});
expect(() => assertCompanyAccess(req, "company-1")).toThrow("Viewer access is read-only");
});
it("rejects writes when membership details are present but omit the target company", () => {
const req = makeReq({
method: "POST",
actor: {
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-1"],
memberships: [],
},
});
expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have active company access");
});
it("allows legacy board actors that only provide company ids", () => {
const req = makeReq({
method: "POST",
actor: {
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-1"],
},
});
expect(() => assertCompanyAccess(req, "company-1")).not.toThrow();
});
it("rejects signed-in instance admins without explicit company access", () => {
const req = makeReq({
method: "GET",
actor: {
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [],
memberships: [],
},
});
expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have access to this company");
});
it("allows local trusted board access without explicit membership", () => {
const req = makeReq({
method: "GET",
actor: {
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
},
});
expect(() => assertCompanyAccess(req, "company-1")).not.toThrow();
});
});
describe("assertBoardOrgAccess", () => {
it("allows signed-in board users with active company access", () => {
const req = makeReq({
actor: {
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }],
isInstanceAdmin: false,
},
});
expect(hasBoardOrgAccess(req)).toBe(true);
expect(() => assertBoardOrgAccess(req)).not.toThrow();
});
it("allows instance admins without company memberships", () => {
const req = makeReq({
actor: {
type: "board",
userId: "admin-1",
source: "session",
companyIds: [],
memberships: [],
isInstanceAdmin: true,
},
});
expect(hasBoardOrgAccess(req)).toBe(true);
expect(() => assertBoardOrgAccess(req)).not.toThrow();
});
it("rejects signed-in users without company access or instance admin rights", () => {
const req = makeReq({
actor: {
type: "board",
userId: "outsider-1",
source: "session",
companyIds: [],
memberships: [],
isInstanceAdmin: false,
},
});
expect(hasBoardOrgAccess(req)).toBe(false);
expect(() => assertBoardOrgAccess(req)).toThrow("Company membership or instance admin access required");
});
});

View file

@ -15,8 +15,6 @@ const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null;
const instructionsIndex = argv.indexOf("--append-system-prompt-file");
const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null;
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file");
const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null;
const payload = {
argv,
prompt: fs.readFileSync(0, "utf8"),
@ -25,8 +23,6 @@ const payload = {
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
appendedSystemPromptFilePath,
appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null,
};
if (capturePath) {
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");

View file

@ -729,7 +729,6 @@ describe("codex execute", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
const workspace = path.join(root, "workspace");

View file

@ -0,0 +1,132 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
vi.mock("../services/index.js", () => ({
accessService: () => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
boardAuthService: () => ({
createChallenge: vi.fn(),
resolveBoardAccess: vi.fn(),
assertCurrentBoardKey: vi.fn(),
revokeBoardApiKey: vi.fn(),
}),
deduplicateAgentName: vi.fn(),
logActivity: vi.fn(),
notifyHireApproved: vi.fn(),
}));
function createDbStub() {
const activeMemberships = [
{ principalId: "user-2", status: "active" as const },
{ principalId: "user-1", status: "active" as const },
];
const users = [
{ id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" },
{ id: "user-2", name: null, email: "alex@example.com", image: null },
];
const isCompanyMembershipsTable = (table: unknown) =>
!!table &&
typeof table === "object" &&
"membershipRole" in table &&
"principalType" in table &&
"principalId" in table;
const isAuthUsersTable = (table: unknown) =>
!!table &&
typeof table === "object" &&
"emailVerified" in table &&
"createdAt" in table &&
"updatedAt" in table;
return {
select() {
return {
from(table: unknown) {
if (isCompanyMembershipsTable(table)) {
const query = {
where() {
return query;
},
orderBy() {
return Promise.resolve(activeMemberships);
},
};
return query;
}
if (isAuthUsersTable(table)) {
return {
where() {
return Promise.resolve(users);
},
};
}
throw new Error("Unexpected table");
},
};
},
};
}
function createApp(actor: Express.Request["actor"]) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use(
"/api",
accessRoutes(createDbStub() as never, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("GET /companies/:companyId/user-directory", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns active human users for operators without manage-permissions access", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }],
});
const res = await request(app).get("/api/companies/company-1/user-directory");
expect(res.status).toBe(200);
expect(res.body).toEqual({
users: [
{
principalId: "user-2",
status: "active",
user: { id: "user-2", name: null, email: "alex@example.com", image: null },
},
{
principalId: "user-1",
status: "active",
user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" },
},
],
});
});
});

View file

@ -12,7 +12,6 @@ import {
issues,
projectWorkspaces,
projects,
workspaceRuntimeServices,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@ -136,7 +135,6 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
afterEach(async () => {
await db.delete(issues);
await db.delete(workspaceRuntimeServices);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
@ -326,136 +324,4 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
"git_branch_delete",
]));
}, 20_000);
it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const olderServiceId = randomUUID();
const currentServiceId = randomUUID();
const reuseKey = `project_workspace:${projectWorkspaceId}:paperclip-dev`;
const startedAt = new Date("2026-04-04T17:00:00.000Z");
const stoppedAt = new Date("2026-04-04T17:05:00.000Z");
const runningAt = new Date("2026-04-04T17:10:00.000Z");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
isPrimary: true,
cwd: "/tmp/paperclip-primary",
metadata: {
runtimeConfig: {
desiredState: "running",
workspaceRuntime: {
services: [{ name: "paperclip-dev", command: "pnpm dev" }],
},
},
},
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared workspace",
status: "active",
providerType: "local_fs",
cwd: "/tmp/paperclip-primary",
});
await db.insert(workspaceRuntimeServices).values([
{
id: olderServiceId,
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId: null,
issueId: null,
scopeType: "project_workspace",
scopeId: projectWorkspaceId,
serviceName: "paperclip-dev",
status: "stopped",
lifecycle: "shared",
reuseKey,
command: "pnpm dev",
cwd: "/tmp/paperclip-primary",
port: 49195,
url: "http://127.0.0.1:49195",
provider: "local_process",
providerRef: "11111",
ownerAgentId: null,
startedByRunId: null,
lastUsedAt: stoppedAt,
startedAt,
stoppedAt,
stopPolicy: { type: "manual" },
healthStatus: "unknown",
createdAt: startedAt,
updatedAt: stoppedAt,
},
{
id: currentServiceId,
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId: null,
issueId: null,
scopeType: "project_workspace",
scopeId: projectWorkspaceId,
serviceName: "paperclip-dev",
status: "running",
lifecycle: "shared",
reuseKey,
command: "pnpm dev",
cwd: "/tmp/paperclip-primary",
port: 49222,
url: "http://127.0.0.1:49222",
provider: "local_process",
providerRef: "22222",
ownerAgentId: null,
startedByRunId: null,
lastUsedAt: runningAt,
startedAt: runningAt,
stoppedAt: null,
stopPolicy: { type: "manual" },
healthStatus: "healthy",
createdAt: runningAt,
updatedAt: runningAt,
},
]);
const workspace = await svc.getById(executionWorkspaceId);
const listed = await svc.list(companyId, { projectId });
expect(workspace?.runtimeServices).toHaveLength(1);
expect(workspace?.runtimeServices?.[0]).toMatchObject({
id: currentServiceId,
status: "running",
projectWorkspaceId,
executionWorkspaceId: null,
url: "http://127.0.0.1:49222",
});
expect(listed[0]?.runtimeServices).toHaveLength(1);
expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId);
});
});

View file

@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
import type { Db } from "@paperclipai/db";
import { serverVersion } from "../version.js";
import { healthRoutes } from "../routes/health.js";
import * as devServerStatus from "../dev-server-status.js";
import { serverVersion } from "../version.js";
const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn());
@ -27,10 +28,8 @@ describe("GET /health", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns 200 with status ok", async () => {
const app = createApp();
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "ok", version: serverVersion });
@ -45,6 +44,7 @@ describe("GET /health", () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(db.execute).toHaveBeenCalledTimes(1);
expect(res.body).toMatchObject({ status: "ok", version: serverVersion });
});
@ -60,7 +60,7 @@ describe("GET /health", () => {
expect(res.body).toEqual({
status: "unhealthy",
version: serverVersion,
error: "database_unreachable",
error: "database_unreachable"
});
});

View file

@ -536,7 +536,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
it("tracks the first heartbeat with the agent role instead of adapter type", async () => {
const { runId } = await seedRunFixture({
const { agentId, runId } = await seedRunFixture({
agentStatus: "running",
includeIssue: false,
});
@ -548,6 +548,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
mockTelemetryClient,
expect.objectContaining({
agentRole: "engineer",
agentId,
}),
);
});

View file

@ -201,44 +201,6 @@ describe("buildRealizedExecutionWorkspaceFromPersisted", () => {
expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature");
expect(result.source).toBe("task_session");
});
it("falls back to realization when the persisted workspace has no local path yet", () => {
const result = buildRealizedExecutionWorkspaceFromPersisted({
base: buildResolvedWorkspace({
cwd: "/tmp/project-primary",
repoRef: "main",
}),
workspace: {
id: "execution-workspace-2",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
sourceIssueId: "issue-2",
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "PAP-999-missing-provider-ref",
status: "active",
cwd: null,
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "feature/PAP-999-missing-provider-ref",
providerType: "git_worktree",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date(),
openedAt: new Date(),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
},
});
expect(result).toBeNull();
});
});
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {

View file

@ -176,6 +176,22 @@ describe("instance settings routes", () => {
});
});
it("rejects signed-in users without company access from reading general settings", async () => {
const app = await createApp({
type: "board",
userId: "user-2",
source: "session",
isInstanceAdmin: false,
companyIds: [],
memberships: [],
});
const res = await request(app).get("/api/instance/settings/general");
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
});
it("rejects non-admin board users from updating general settings", async () => {
const app = await createApp({
type: "board",

View file

@ -0,0 +1,126 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
vi.mock("../services/index.js", () => ({
accessService: () => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
boardAuthService: () => ({
createChallenge: vi.fn(),
resolveBoardAccess: vi.fn(),
assertCurrentBoardKey: vi.fn(),
revokeBoardApiKey: vi.fn(),
}),
deduplicateAgentName: vi.fn(),
logActivity: vi.fn(),
notifyHireApproved: vi.fn(),
}));
function createDbStub() {
const updateMock = vi.fn();
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: { humanRole: "viewer" },
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
invitedByUserId: "user-1",
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const db = {
select() {
return {
from() {
return {
where() {
return Promise.resolve([invite]);
},
};
},
};
},
update(...args: unknown[]) {
updateMock(...args);
return {
set() {
return {
where() {
return {
returning() {
return Promise.resolve([]);
},
};
},
};
},
};
},
};
return { db, updateMock };
}
function createApp(db: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
source: "session",
userId: "user-1",
companyIds: ["company-1"],
memberships: [
{
companyId: "company-1",
membershipRole: "owner",
status: "active",
},
],
};
next();
});
app.use(
"/api",
accessRoutes(db as any, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("POST /invites/:token/accept", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not consume a human invite when the signed-in user is already a company member", async () => {
const { db, updateMock } = createDbStub();
const app = createApp(db);
const res = await request(app)
.post("/api/invites/pcp_invite_test/accept")
.send({ requestType: "human" });
expect(res.status).toBe(409);
expect(res.body.error).toBe("You already belong to this company");
expect(updateMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,126 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const logActivityMock = vi.fn();
vi.mock("../services/index.js", () => ({
accessService: () => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
boardAuthService: () => ({
createChallenge: vi.fn(),
resolveBoardAccess: vi.fn(),
assertCurrentBoardKey: vi.fn(),
revokeBoardApiKey: vi.fn(),
}),
deduplicateAgentName: vi.fn(),
logActivity: (...args: unknown[]) => logActivityMock(...args),
notifyHireApproved: vi.fn(),
}));
function createDbStub() {
const createdInvite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: { humanRole: "viewer" },
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
return {
insert() {
return {
values() {
return {
returning() {
return Promise.resolve([createdInvite]);
},
};
},
};
},
select(_shape?: unknown) {
return {
from() {
const query = {
leftJoin() {
return query;
},
where() {
return Promise.resolve([{
name: "Acme Robotics",
brandColor: "#114488",
logoAssetId: "logo-1",
}]);
},
};
return query;
},
};
},
};
}
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
source: "local_implicit",
userId: null,
companyIds: ["company-1"],
};
next();
});
app.use(
"/api",
accessRoutes(createDbStub() as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("POST /companies/:companyId/invites", () => {
beforeEach(() => {
logActivityMock.mockReset();
});
it("returns an absolute invite URL using the request base URL", async () => {
const app = createApp();
const res = await request(app)
.post("/api/companies/company-1/invites")
.set("host", "paperclip.example")
.set("x-forwarded-proto", "https")
.send({
allowedJoinTypes: "human",
humanRole: "viewer",
});
expect(res.status).toBe(201);
expect(res.body.companyName).toBe("Acme Robotics");
expect(res.body.invitePath).toMatch(/^\/invite\/pcp_invite_/);
expect(res.body.inviteUrl).toMatch(/^https:\/\/paperclip\.example\/invite\/pcp_invite_/);
});
});

View file

@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
import { companyInviteExpiresAt } from "../routes/access.js";
describe("companyInviteExpiresAt", () => {
it("sets invite expiration to 10 minutes after invite creation time", () => {
it("sets invite expiration to 72 hours after invite creation time", () => {
const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z");
const expiresAt = companyInviteExpiresAt(createdAtMs);
expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z");
expect(expiresAt.toISOString()).toBe("2026-03-09T00:00:00.000Z");
});
});

View file

@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { agentJoinGrantsFromDefaults } from "../routes/access.js";
import {
agentJoinGrantsFromDefaults,
humanJoinGrantsFromDefaults,
} from "../services/invite-grants.js";
import {
grantsForHumanRole,
normalizeHumanRole,
resolveHumanInviteRole,
} from "../services/company-member-roles.js";
describe("agentJoinGrantsFromDefaults", () => {
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
@ -55,3 +63,59 @@ describe("agentJoinGrantsFromDefaults", () => {
]);
});
});
describe("human invite roles", () => {
it("maps owner to the full management grant set", () => {
expect(grantsForHumanRole("owner")).toEqual([
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
]);
});
it("defaults legacy or missing roles to operator", () => {
expect(normalizeHumanRole("member")).toBe("operator");
expect(resolveHumanInviteRole(null)).toBe("operator");
});
it("reads the configured human invite role from defaults", () => {
expect(
resolveHumanInviteRole({
human: {
role: "viewer",
},
}),
).toBe("viewer");
});
it("falls back to role grants when human invite defaults omit explicit grants", () => {
expect(humanJoinGrantsFromDefaults(null, "operator")).toEqual([
{ permissionKey: "tasks:assign", scope: null },
]);
});
it("preserves explicit human invite grants", () => {
expect(
humanJoinGrantsFromDefaults(
{
human: {
grants: [
{
permissionKey: "users:invite",
scope: { companyId: "company-1" },
},
],
},
},
"operator",
),
).toEqual([
{
permissionKey: "users:invite",
scope: { companyId: "company-1" },
},
]);
});
});

View file

@ -0,0 +1,164 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { companies, createDb, invites, joinRequests } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
vi.mock("../services/index.js", () => ({
accessService: () => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
boardAuthService: () => ({
createChallenge: vi.fn(),
resolveBoardAccess: vi.fn(),
assertCurrentBoardKey: vi.fn(),
revokeBoardApiKey: vi.fn(),
}),
deduplicateAgentName: vi.fn(),
logActivity: vi.fn(),
notifyHireApproved: vi.fn(),
}));
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres invite list route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("GET /companies/:companyId/invites", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let companyId!: string;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-invite-list-route-");
db = createDb(tempDb.connectionString);
}, 20_000);
beforeEach(async () => {
companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
});
afterEach(async () => {
await db.delete(joinRequests);
await db.delete(invites);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
function createApp(currentCompanyId: string) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
source: "local_implicit",
userId: null,
companyIds: [currentCompanyId],
};
next();
});
app.use(
"/api",
accessRoutes(db, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
it("returns invite history in descending pages with nextOffset", async () => {
const inviteOneId = randomUUID();
const inviteTwoId = randomUUID();
const inviteThreeId = randomUUID();
await db.insert(invites).values([
{
id: inviteOneId,
companyId,
inviteType: "company_join",
tokenHash: "invite-token-1",
allowedJoinTypes: "human",
defaultsPayload: { humanRole: "viewer" },
expiresAt: new Date("2026-04-20T00:00:00.000Z"),
createdAt: new Date("2026-04-10T00:00:00.000Z"),
updatedAt: new Date("2026-04-10T00:00:00.000Z"),
},
{
id: inviteTwoId,
companyId,
inviteType: "company_join",
tokenHash: "invite-token-2",
allowedJoinTypes: "human",
defaultsPayload: { humanRole: "operator" },
expiresAt: new Date("2026-04-21T00:00:00.000Z"),
createdAt: new Date("2026-04-11T00:00:00.000Z"),
updatedAt: new Date("2026-04-11T00:00:00.000Z"),
},
{
id: inviteThreeId,
companyId,
inviteType: "company_join",
tokenHash: "invite-token-3",
allowedJoinTypes: "human",
defaultsPayload: { humanRole: "admin" },
expiresAt: new Date("2026-04-22T00:00:00.000Z"),
createdAt: new Date("2026-04-12T00:00:00.000Z"),
updatedAt: new Date("2026-04-12T00:00:00.000Z"),
},
]);
await db.insert(joinRequests).values({
id: randomUUID(),
inviteId: inviteThreeId,
companyId,
requestType: "human",
status: "pending_approval",
requestIp: "127.0.0.1",
requestEmailSnapshot: "person@example.com",
createdAt: new Date("2026-04-12T00:05:00.000Z"),
updatedAt: new Date("2026-04-12T00:05:00.000Z"),
});
const app = createApp(companyId);
const firstPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2`);
expect(firstPage.status).toBe(200);
expect(firstPage.body.invites).toHaveLength(2);
expect(firstPage.body.invites.map((invite: { id: string }) => invite.id)).toEqual([inviteThreeId, inviteTwoId]);
expect(firstPage.body.invites[0].relatedJoinRequestId).toBeTruthy();
expect(firstPage.body.nextOffset).toBe(2);
const secondPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2&offset=2`);
expect(secondPage.status).toBe(200);
expect(secondPage.body.invites).toHaveLength(1);
expect(secondPage.body.invites[0].id).toBe(inviteOneId);
expect(secondPage.body.nextOffset).toBeNull();
});
});

View file

@ -0,0 +1,146 @@
import { Readable } from "node:stream";
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockStorage = vi.hoisted(() => ({
getObject: vi.fn(),
headObject: vi.fn(),
}));
vi.mock("../storage/index.js", () => ({
getStorageService: () => mockStorage,
}));
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
function createSelectChain(rows: unknown[]) {
const query = {
leftJoin() {
return query;
},
where() {
return Promise.resolve(rows);
},
};
return {
from() {
return query;
},
};
}
function createDbStub(inviteRows: unknown[], companyRows: unknown[]) {
let selectCall = 0;
return {
select() {
selectCall += 1;
return selectCall === 1
? createSelectChain(inviteRows)
: createSelectChain(companyRows);
},
};
}
function createApp(db: Record<string, unknown>) {
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "anon" };
next();
});
app.use(
"/api",
accessRoutes(db as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("GET /invites/:token/logo", () => {
beforeEach(() => {
mockStorage.getObject.mockReset();
mockStorage.headObject.mockReset();
});
it("serves the company logo for an active invite without company auth", async () => {
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
mockStorage.headObject.mockResolvedValue({
exists: true,
contentType: "image/png",
contentLength: 3,
});
mockStorage.getObject.mockResolvedValue({
contentType: "image/png",
contentLength: 3,
stream: Readable.from([Buffer.from("png")]),
});
const app = createApp(
createDbStub([invite], [{
companyId: "company-1",
objectKey: "assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
}]),
);
const res = await request(app).get("/api/invites/pcp_invite_test/logo");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("image/png");
expect(mockStorage.headObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1");
expect(mockStorage.getObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1");
});
it("returns 404 when the logo asset record exists but storage does not", async () => {
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
mockStorage.headObject.mockResolvedValue({ exists: false });
const app = createApp(
createDbStub([invite], [{
companyId: "company-1",
objectKey: "assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
}]),
);
const res = await request(app).get("/api/invites/pcp_invite_test/logo");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Invite logo not found");
expect(mockStorage.getObject).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,270 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockStorage = vi.hoisted(() => ({
headObject: vi.fn(),
}));
vi.mock("../storage/index.js", () => ({
getStorageService: () => mockStorage,
}));
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
function createSelectChain(rows: unknown[]) {
const query = {
then(resolve: (value: unknown[]) => unknown) {
return Promise.resolve(rows).then(resolve);
},
leftJoin() {
return query;
},
orderBy() {
return query;
},
where() {
return query;
},
};
return {
from() {
return query;
},
};
}
function createDbStub(...selectResponses: unknown[][]) {
let selectCall = 0;
return {
select() {
const rows = selectResponses[selectCall] ?? [];
selectCall += 1;
return createSelectChain(rows);
},
};
}
function createApp(
db: Record<string, unknown>,
actor: Record<string, unknown> = { type: "anon" },
) {
const app = express();
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use(
"/api",
accessRoutes(db as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("GET /invites/:token", () => {
beforeEach(() => {
mockStorage.headObject.mockReset();
mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" });
});
it("returns company branding in the invite summary response", async () => {
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const app = createApp(
createDbStub(
[invite],
[
{
name: "Acme Robotics",
brandColor: "#114488",
logoAssetId: "logo-1",
},
],
[
{
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
},
],
),
);
const res = await request(app).get("/api/invites/pcp_invite_test");
expect(res.status).toBe(200);
expect(res.body.companyId).toBe("company-1");
expect(res.body.companyName).toBe("Acme Robotics");
expect(res.body.companyBrandColor).toBe("#114488");
expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo");
expect(res.body.inviteType).toBe("company_join");
});
it("omits companyLogoUrl when the stored logo object is missing", async () => {
mockStorage.headObject.mockResolvedValue({ exists: false });
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const app = createApp(
createDbStub(
[invite],
[
{
name: "Acme Robotics",
brandColor: "#114488",
logoAssetId: "logo-1",
},
],
[
{
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
},
],
),
);
const res = await request(app).get("/api/invites/pcp_invite_test");
expect(res.status).toBe(200);
expect(res.body.companyLogoUrl).toBeNull();
});
it("returns pending join-request status for an already-accepted invite", async () => {
const invite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: new Date("2026-03-07T00:05:00.000Z"),
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:05:00.000Z"),
};
const app = createApp(
createDbStub(
[invite],
[{ requestType: "human", status: "pending_approval" }],
[
{
name: "Acme Robotics",
brandColor: "#114488",
logoAssetId: "logo-1",
},
],
[
{
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
},
],
),
);
const res = await request(app).get("/api/invites/pcp_invite_test");
expect(res.status).toBe(200);
expect(res.body.joinRequestStatus).toBe("pending_approval");
expect(res.body.joinRequestType).toBe("human");
expect(res.body.companyName).toBe("Acme Robotics");
});
it("falls back to a reusable human join request when the accepted invite reused an existing queue entry", async () => {
const invite = {
id: "invite-2",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "human",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2027-03-07T00:10:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: new Date("2026-03-07T00:05:00.000Z"),
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:05:00.000Z"),
};
const app = createApp(
createDbStub(
[invite],
[],
[{ email: "jane@example.com" }],
[
{
id: "join-1",
requestType: "human",
status: "pending_approval",
requestingUserId: "user-1",
requestEmailSnapshot: "jane@example.com",
},
],
[
{
name: "Acme Robotics",
brandColor: "#114488",
logoAssetId: "logo-1",
},
],
[
{
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
},
],
),
{ type: "board", userId: "user-1", source: "session" },
);
const res = await request(app).get("/api/invites/pcp_invite_test");
expect(res.status).toBe(200);
expect(res.body.joinRequestStatus).toBe("pending_approval");
expect(res.body.joinRequestType).toBe("human");
});
});

View file

@ -180,7 +180,6 @@ describe("issue feedback trace routes", () => {
});
const res = await request(app).get("/api/feedback-traces/trace-1");
expect(res.status).toBe(403);
expect(mockFeedbackService.getFeedbackTraceById).not.toHaveBeenCalled();
});

View file

@ -379,7 +379,6 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
it("returns null instead of throwing for malformed non-uuid issue refs", async () => {
await expect(svc.getById("not-a-uuid")).resolves.toBeNull();
});
it("filters issues by execution workspace id", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
@ -1217,6 +1216,283 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
});
});
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
await ensureIssueRelationsTable(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueRelations);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
sharedWorkspaceKey: "workspace-key",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Issue worktree",
status: "active",
providerType: "git_worktree",
providerRef: `/tmp/${executionWorkspaceId}`,
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceRuntime: { profile: "agent" },
},
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
projectId,
title: "Child issue",
});
expect(child.parentId).toBe(parentIssueId);
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
expect(child.executionWorkspacePreference).toBe("reuse_existing");
expect(child.executionWorkspaceSettings).toEqual({
mode: "isolated_workspace",
workspaceRuntime: { profile: "agent" },
});
});
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const parentProjectWorkspaceId = randomUUID();
const parentExecutionWorkspaceId = randomUUID();
const explicitProjectWorkspaceId = randomUUID();
const explicitExecutionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values([
{
id: parentProjectWorkspaceId,
companyId,
projectId,
name: "Parent workspace",
},
{
id: explicitProjectWorkspaceId,
companyId,
projectId,
name: "Explicit workspace",
},
]);
await db.insert(executionWorkspaces).values([
{
id: parentExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId: parentProjectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Parent worktree",
status: "active",
providerType: "git_worktree",
},
{
id: explicitExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId: explicitProjectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Explicit shared workspace",
status: "active",
providerType: "local_fs",
},
]);
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId: parentProjectWorkspaceId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId: parentExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
projectId,
title: "Child issue",
projectWorkspaceId: explicitProjectWorkspaceId,
executionWorkspaceId: explicitExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "shared_workspace",
},
});
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
expect(child.executionWorkspacePreference).toBe("reuse_existing");
expect(child.executionWorkspaceSettings).toEqual({
mode: "shared_workspace",
});
});
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const sourceIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "operator_branch",
strategyType: "git_worktree",
name: "Operator branch",
status: "active",
providerType: "git_worktree",
});
await db.insert(issues).values({
id: sourceIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Source issue",
status: "todo",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "operator_branch",
},
});
const followUp = await svc.create(companyId, {
projectId,
title: "Follow-up issue",
inheritExecutionWorkspaceFromIssueId: sourceIssueId,
});
expect(followUp.parentId).toBeNull();
expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId);
expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId);
expect(followUp.executionWorkspacePreference).toBe("reuse_existing");
expect(followUp.executionWorkspaceSettings).toEqual({
mode: "operator_branch",
});
});
});
describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;

View file

@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
collapseDuplicatePendingHumanJoinRequests,
findReusableHumanJoinRequest,
} from "../lib/join-request-dedupe.js";
describe("findReusableHumanJoinRequest", () => {
it("reuses the newest pending request for the same user", () => {
const rows = [
{
id: "pending-new",
requestType: "human",
status: "pending_approval",
requestingUserId: "user-1",
requestEmailSnapshot: "person@example.com",
},
{
id: "pending-old",
requestType: "human",
status: "pending_approval",
requestingUserId: "user-1",
requestEmailSnapshot: "person@example.com",
},
{
id: "other-user",
requestType: "human",
status: "pending_approval",
requestingUserId: "user-2",
requestEmailSnapshot: "other@example.com",
},
] as const;
expect(
findReusableHumanJoinRequest(rows, {
requestingUserId: "user-1",
requestEmailSnapshot: "person@example.com",
})?.id
).toBe("pending-new");
});
it("falls back to email matching when the user id is unavailable", () => {
const rows = [
{
id: "approved-existing",
requestType: "human",
status: "approved",
requestingUserId: null,
requestEmailSnapshot: "Person@Example.com",
},
{
id: "agent-request",
requestType: "agent",
status: "pending_approval",
requestingUserId: null,
requestEmailSnapshot: null,
},
] as const;
expect(
findReusableHumanJoinRequest(rows, {
requestingUserId: null,
requestEmailSnapshot: "person@example.com",
})?.id
).toBe("approved-existing");
});
});
describe("collapseDuplicatePendingHumanJoinRequests", () => {
it("keeps only the newest pending human row per requester", () => {
const rows = [
{
id: "human-new",
requestType: "human",
status: "pending_approval",
requestingUserId: "user-1",
requestEmailSnapshot: "person@example.com",
},
{
id: "human-old",
requestType: "human",
status: "pending_approval",
requestingUserId: "user-1",
requestEmailSnapshot: "person@example.com",
},
{
id: "approved-history",
requestType: "human",
status: "approved",
requestingUserId: "user-1",
requestEmailSnapshot: "person@example.com",
},
{
id: "agent-pending",
requestType: "agent",
status: "pending_approval",
requestingUserId: null,
requestEmailSnapshot: null,
},
] as const;
expect(collapseDuplicatePendingHumanJoinRequests(rows).map((row) => row.id))
.toEqual(["human-new", "approved-history", "agent-pending"]);
});
});

View file

@ -32,6 +32,9 @@ const mockBoardAuthService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockStorage = vi.hoisted(() => ({
headObject: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
@ -44,6 +47,10 @@ function registerModuleMocks() {
}));
}
vi.mock("../storage/index.js", () => ({
getStorageService: () => mockStorage,
}));
function createDbStub() {
const createdInvite = {
id: "invite-1",
@ -76,20 +83,35 @@ function createDbStub() {
"feedbackDataSharingEnabled" in table;
const select = vi.fn((selection?: unknown) => ({
from(table: unknown) {
return {
const query = {
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockImplementation(() => {
if (isInvitesTable(table)) {
return Promise.resolve([createdInvite]);
}
if (selection && typeof selection === "object" && "objectKey" in selection) {
return Promise.resolve([{
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
}]);
}
if (
(selection && typeof selection === "object" && "name" in selection) ||
isCompaniesTable(table)
) {
return Promise.resolve([{ name: "Acme AI" }]);
return Promise.resolve([{
name: "Acme AI",
brandColor: "#225577",
logoAssetId: "logo-1",
}]);
}
return Promise.resolve([]);
}),
};
return query;
},
}));
return {
@ -135,6 +157,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
mockAccessService.canUser.mockResolvedValue(false);
mockAgentService.getById.mockReset();
mockLogActivity.mockResolvedValue(undefined);
mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" });
});
it("rejects non-CEO agent callers", async () => {
@ -212,6 +235,8 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
expect(res.status).toBe(200);
expect(res.body.companyName).toBe("Acme AI");
expect(res.body.companyBrandColor).toBe("#225577");
expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo");
expect(res.body.inviteType).toBe("company_join");
expect(res.body.allowedJoinTypes).toBe("agent");
});

View file

@ -5,11 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
upsertConfig: vi.fn(),
}));
const mockLifecycle = vi.hoisted(() => ({
load: vi.fn(),
upgrade: vi.fn(),
unload: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
}));
vi.mock("../services/plugin-registry.js", () => ({
@ -138,6 +142,31 @@ describe("plugin install and upgrade authz", () => {
expect(mockLifecycle.upgrade).not.toHaveBeenCalled();
}, 20_000);
it.each([
["delete", "delete", "/api/plugins/11111111-1111-4111-8111-111111111111", undefined],
["enable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/enable", {}],
["disable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/disable", {}],
["config", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/config", { configJson: {} }],
] as const)("rejects plugin %s for non-admin board users", async (_name, method, path, body) => {
const { app } = await createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const req = method === "delete" ? request(app).delete(path) : request(app).post(path).send(body);
const res = await req;
expect(res.status).toBe(403);
expect(mockRegistry.getById).not.toHaveBeenCalled();
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
expect(mockLifecycle.unload).not.toHaveBeenCalled();
expect(mockLifecycle.enable).not.toHaveBeenCalled();
expect(mockLifecycle.disable).not.toHaveBeenCalled();
}, 20_000);
it("allows instance admins to upgrade plugins", async () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
mockRegistry.getById.mockResolvedValue({

View file

@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import {
trackAgentCreated,
trackAgentFirstHeartbeat,
trackAgentTaskCompleted,
trackInstallCompleted,
} from "@paperclipai/shared/telemetry";
import type { TelemetryClient } from "@paperclipai/shared/telemetry";
function createClient(): TelemetryClient {
return {
track: vi.fn(),
hashPrivateRef: vi.fn((value: string) => `hashed:${value}`),
} as unknown as TelemetryClient;
}
describe("shared telemetry agent events", () => {
it("includes agent_id for agent.created", () => {
const client = createClient();
trackAgentCreated(client, {
agentRole: "engineer",
agentId: "11111111-1111-4111-8111-111111111111",
});
expect(client.track).toHaveBeenCalledWith("agent.created", {
agent_role: "engineer",
agent_id: "11111111-1111-4111-8111-111111111111",
});
});
it("includes agent_id for agent.first_heartbeat", () => {
const client = createClient();
trackAgentFirstHeartbeat(client, {
agentRole: "coder",
agentId: "22222222-2222-4222-8222-222222222222",
});
expect(client.track).toHaveBeenCalledWith("agent.first_heartbeat", {
agent_role: "coder",
agent_id: "22222222-2222-4222-8222-222222222222",
});
});
it("includes agent_id for agent.task_completed", () => {
const client = createClient();
trackAgentTaskCompleted(client, {
agentRole: "qa",
agentId: "33333333-3333-4333-8333-333333333333",
});
expect(client.track).toHaveBeenCalledWith("agent.task_completed", {
agent_role: "qa",
agent_id: "33333333-3333-4333-8333-333333333333",
});
});
it("keeps non-agent event dimensions unchanged", () => {
const client = createClient();
trackInstallCompleted(client, { adapterType: "codex_local" });
expect(client.track).toHaveBeenCalledWith("install.completed", {
adapter_type: "codex_local",
});
expect(client.track).not.toHaveBeenCalledWith(
"install.completed",
expect.objectContaining({ agent_id: expect.any(String) }),
);
});
});

View file

@ -206,6 +206,113 @@ describe("worktree config repair", () => {
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
});
it("does not persist transient runtime home overrides over repo-local worktree env", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-"));
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
const transientHome = path.join(tempRoot, "tests", "e2e", ".tmp", "multiuser-authenticated");
const worktreeRoot = path.join(tempRoot, "PAP-989-multi-user-implementation-using-plan-from-pap-958");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
const configPath = path.join(paperclipDir, "config.json");
const envPath = path.join(paperclipDir, ".env");
const instanceId = "pap-989-multi-user-implementation-using-plan-from-pap-958";
const stableInstanceRoot = path.join(isolatedHome, "instances", instanceId);
await fs.mkdir(paperclipDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
...buildLegacyConfig(transientHome),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(transientHome, "instances", instanceId, "db"),
embeddedPostgresPort: 54334,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(transientHome, "instances", instanceId, "data", "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(transientHome, "instances", instanceId, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3104,
allowedHostnames: [],
serveUi: true,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(transientHome, "instances", instanceId, "data", "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(transientHome, "instances", instanceId, "secrets", "master.key"),
},
},
},
null,
2,
) + "\n",
"utf8",
);
await fs.writeFile(
envPath,
[
"# Paperclip environment variables",
`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`,
`PAPERCLIP_INSTANCE_ID=${JSON.stringify(instanceId)}`,
`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`,
`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`,
'PAPERCLIP_IN_WORKTREE="true"',
'PAPERCLIP_WORKTREE_NAME="PAP-989-multi-user-implementation-using-plan-from-pap-958"',
"",
].join("\n"),
"utf8",
);
process.chdir(worktreeRoot);
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-989-multi-user-implementation-using-plan-from-pap-958";
process.env.PAPERCLIP_HOME = transientHome;
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
process.env.PAPERCLIP_CONFIG = configPath;
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
const repairedEnv = await fs.readFile(envPath, "utf8");
expect(result).toEqual({
repairedConfig: true,
repairedEnv: false,
});
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(stableInstanceRoot, "db"));
expect(repairedConfig.database.backup.dir).toBe(path.join(stableInstanceRoot, "data", "backups"));
expect(repairedConfig.logging.logDir).toBe(path.join(stableInstanceRoot, "logs"));
expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(stableInstanceRoot, "data", "storage"));
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(
path.join(stableInstanceRoot, "secrets", "master.key"),
);
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
expect(repairedEnv).not.toContain(`PAPERCLIP_HOME=${JSON.stringify(transientHome)}`);
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
});
it("rebalances duplicate ports for already isolated worktree configs", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-"));
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");

View file

@ -38,7 +38,6 @@ export function buildInvocationEnvForLogs(
env: Record<string, string>,
options: BuildInvocationEnvForLogsOptions = {},
): Record<string, string> {
// TODO: Remove this fallback once @paperclipai/adapter-utils exports buildInvocationEnvForLogs everywhere we consume it.
const maybeBuildInvocationEnvForLogs = (
serverUtils as typeof serverUtils & {
buildInvocationEnvForLogs?: (

View file

@ -28,6 +28,7 @@ import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { llmRoutes } from "./routes/llms.js";
import { authRoutes } from "./routes/auth.js";
import { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js";
import { pluginRoutes } from "./routes/plugins.js";
@ -155,23 +156,7 @@ export async function createApp(
resolveSession: opts.resolveSession,
}),
);
app.get("/api/auth/get-session", (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}
res.json({
session: {
id: `paperclip:${req.actor.source}:${req.actor.userId}`,
userId: req.actor.userId,
},
user: {
id: req.actor.userId,
email: null,
name: req.actor.source === "local_implicit" ? "Local Board" : null,
},
});
});
app.use("/api/auth", authRoutes(db));
if (opts.betterAuthHandler) {
app.all("/api/auth/{*authPath}", opts.betterAuthHandler);
}

View file

@ -0,0 +1,88 @@
import { joinRequests } from "@paperclipai/db";
type JoinRequestLike = Pick<
typeof joinRequests.$inferSelect,
| "id"
| "requestType"
| "status"
| "requestingUserId"
| "requestEmailSnapshot"
| "createdAt"
| "updatedAt"
>;
function nonEmptyTrimmed(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
export function normalizeJoinRequestEmail(
email: string | null | undefined
): string | null {
const trimmed = nonEmptyTrimmed(email);
return trimmed ? trimmed.toLowerCase() : null;
}
export function humanJoinRequestIdentity(
row: Pick<
JoinRequestLike,
"requestType" | "requestingUserId" | "requestEmailSnapshot"
>
): string | null {
if (row.requestType !== "human") return null;
const requestingUserId = nonEmptyTrimmed(row.requestingUserId);
if (requestingUserId) return `user:${requestingUserId}`;
const email = normalizeJoinRequestEmail(row.requestEmailSnapshot);
return email ? `email:${email}` : null;
}
export function findReusableHumanJoinRequest<
T extends Pick<
JoinRequestLike,
"id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot"
>,
>(
rows: T[],
actor: { requestingUserId?: string | null; requestEmailSnapshot?: string | null }
): T | null {
const actorUserId = nonEmptyTrimmed(actor.requestingUserId);
if (actorUserId) {
const sameUser = rows.find(
(row) =>
row.requestType === "human" &&
(row.status === "pending_approval" || row.status === "approved") &&
row.requestingUserId === actorUserId
);
if (sameUser) return sameUser;
}
const actorEmail = normalizeJoinRequestEmail(actor.requestEmailSnapshot);
if (!actorEmail) return null;
return (
rows.find(
(row) =>
row.requestType === "human" &&
(row.status === "pending_approval" || row.status === "approved") &&
normalizeJoinRequestEmail(row.requestEmailSnapshot) === actorEmail
) ?? null
);
}
export function collapseDuplicatePendingHumanJoinRequests<
T extends Pick<
JoinRequestLike,
"id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot"
>,
>(rows: T[]): T[] {
const seen = new Set<string>();
return rows.filter((row) => {
if (row.requestType !== "human" || row.status !== "pending_approval") {
return true;
}
const identity = humanJoinRequestIdentity(row);
if (!identity) return true;
if (seen.has(identity)) return false;
seen.add(identity);
return true;
});
}

View file

@ -23,7 +23,14 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
return async (req, _res, next) => {
req.actor =
opts.deploymentMode === "local_trusted"
? { type: "board", userId: "local-board", isInstanceAdmin: true, source: "local_implicit" }
? {
type: "board",
userId: "local-board",
userName: "Local Board",
userEmail: null,
isInstanceAdmin: true,
source: "local_implicit",
}
: { type: "none", source: "none" };
const runIdHeader = req.header("x-paperclip-run-id");
@ -49,7 +56,11 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
.then((rows) => rows[0] ?? null),
db
.select({ companyId: companyMemberships.companyId })
.select({
companyId: companyMemberships.companyId,
membershipRole: companyMemberships.membershipRole,
status: companyMemberships.status,
})
.from(companyMemberships)
.where(
and(
@ -62,7 +73,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
req.actor = {
type: "board",
userId,
userName: session.user.name ?? null,
userEmail: session.user.email ?? null,
companyIds: memberships.map((row) => row.companyId),
memberships,
isInstanceAdmin: Boolean(roleRow),
runId: runIdHeader ?? undefined,
source: "session",
@ -90,7 +104,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
req.actor = {
type: "board",
userId: boardKey.userId,
userName: access.user?.name ?? null,
userEmail: access.user?.email ?? null,
companyIds: access.companyIds,
memberships: access.memberships,
isInstanceAdmin: access.isInstanceAdmin,
keyId: boardKey.id,
runId: runIdHeader || undefined,

File diff suppressed because it is too large Load diff

View file

@ -41,7 +41,7 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
import { logger } from "../middleware/logger.js";
import { assertBoard } from "./authz.js";
import { assertBoardOrgAccess } from "./authz.js";
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
const execFileAsync = promisify(execFile);
@ -192,7 +192,7 @@ export function adapterRoutes() {
* its model count, and load status.
*/
router.get("/adapters", async (_req, res) => {
assertBoard(_req);
assertBoardOrgAccess(_req);
const registeredAdapters = listServerAdapters();
const externalRecords = new Map(
@ -218,7 +218,7 @@ export function adapterRoutes() {
* - version?: string target version for npm packages
*/
router.post("/adapters/install", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest;
@ -350,7 +350,7 @@ export function adapterRoutes() {
* Request body: { "disabled": boolean }
*/
router.patch("/adapters/:type", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const adapterType = req.params.type;
const { disabled } = req.body as { disabled?: boolean };
@ -385,7 +385,7 @@ export function adapterRoutes() {
* keep the adapter they started with.
*/
router.patch("/adapters/:type/override", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const adapterType = req.params.type;
const { paused } = req.body as { paused?: boolean };
@ -413,7 +413,7 @@ export function adapterRoutes() {
* Unregister an external adapter. Built-in adapters cannot be removed.
*/
router.delete("/adapters/:type", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const adapterType = req.params.type;
@ -488,7 +488,7 @@ export function adapterRoutes() {
* Cannot be used on built-in adapter types.
*/
router.post("/adapters/:type/reload", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const type = req.params.type;
@ -540,7 +540,7 @@ export function adapterRoutes() {
// This is a convenience shortcut for remove + install with the same
// package name, but without the risk of losing the store record.
router.post("/adapters/:type/reinstall", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const type = req.params.type;
@ -613,7 +613,7 @@ export function adapterRoutes() {
const CONFIG_SCHEMA_TTL_MS = 30_000;
router.get("/adapters/:type/config-schema", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const { type } = req.params;
const adapter = findActiveServerAdapter(type);
@ -651,7 +651,7 @@ export function adapterRoutes() {
// The adapter package must export a "./ui-parser" entry in package.json
// pointing to a self-contained ESM module with zero runtime dependencies.
router.get("/adapters/:type/ui-parser.js", (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const { type } = req.params;
const source = getOrExtractUiParserSource(type);
if (!source) {

100
server/src/routes/auth.ts Normal file
View file

@ -0,0 +1,100 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { authUsers } from "@paperclipai/db";
import {
authSessionSchema,
currentUserProfileSchema,
updateCurrentUserProfileSchema,
} from "@paperclipai/shared";
import { unauthorized } from "../errors.js";
import { validate } from "../middleware/validate.js";
async function loadCurrentUserProfile(db: Db, userId: string) {
const user = await db
.select({
id: authUsers.id,
email: authUsers.email,
name: authUsers.name,
image: authUsers.image,
})
.from(authUsers)
.where(eq(authUsers.id, userId))
.then((rows) => rows[0] ?? null);
if (!user) {
throw unauthorized("Signed-in user not found");
}
return currentUserProfileSchema.parse({
id: user.id,
email: user.email ?? null,
name: user.name ?? null,
image: user.image ?? null,
});
}
export function authRoutes(db: Db) {
const router = Router();
router.get("/get-session", async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
const user = await loadCurrentUserProfile(db, req.actor.userId);
res.json(authSessionSchema.parse({
session: {
id: `paperclip:${req.actor.source ?? "none"}:${req.actor.userId}`,
userId: req.actor.userId,
},
user,
}));
});
router.get("/profile", async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
res.json(await loadCurrentUserProfile(db, req.actor.userId));
});
router.patch("/profile", validate(updateCurrentUserProfileSchema), async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
const patch = updateCurrentUserProfileSchema.parse(req.body);
const now = new Date();
const updated = await db
.update(authUsers)
.set({
name: patch.name,
...(patch.image !== undefined ? { image: patch.image } : {}),
updatedAt: now,
})
.where(eq(authUsers.id, req.actor.userId))
.returning({
id: authUsers.id,
email: authUsers.email,
name: authUsers.name,
image: authUsers.image,
})
.then((rows) => rows[0] ?? null);
if (!updated) {
throw unauthorized("Signed-in user not found");
}
res.json(currentUserProfileSchema.parse({
id: updated.id,
email: updated.email ?? null,
name: updated.name ?? null,
image: updated.image ?? null,
}));
});
return router;
}

View file

@ -13,6 +13,24 @@ export function assertBoard(req: Request) {
}
}
export function hasBoardOrgAccess(req: Request) {
if (req.actor.type !== "board") {
return false;
}
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
return true;
}
return Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0;
}
export function assertBoardOrgAccess(req: Request) {
assertBoard(req);
if (hasBoardOrgAccess(req)) {
return;
}
throw forbidden("Company membership or instance admin access required");
}
export function assertInstanceAdmin(req: Request) {
assertBoard(req);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
@ -26,11 +44,22 @@ export function assertCompanyAccess(req: Request, companyId: string) {
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (req.actor.type === "board" && req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) {
if (req.actor.type === "board" && req.actor.source !== "local_implicit") {
const allowedCompanies = req.actor.companyIds ?? [];
if (!allowedCompanies.includes(companyId)) {
throw forbidden("User does not have access to this company");
}
const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET";
const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method);
if (!isSafeMethod && !req.actor.isInstanceAdmin && Array.isArray(req.actor.memberships)) {
const membership = req.actor.memberships.find((item) => item.companyId === companyId);
if (!membership || membership.status !== "active") {
throw forbidden("User does not have active company access");
}
if (membership.membershipRole === "viewer") {
throw forbidden("Viewer access is read-only");
}
}
}
}

View file

@ -4,6 +4,7 @@ import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
import { logger } from "../middleware/logger.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { serverVersion } from "../version.js";
@ -49,11 +50,12 @@ export function healthRoutes(
try {
await db.execute(sql`SELECT 1`);
} catch {
} catch (error) {
logger.warn({ err: error }, "Health check database probe failed");
res.status(503).json({
status: "unhealthy",
version: serverVersion,
error: "database_unreachable",
error: "database_unreachable"
});
return;
}

View file

@ -4,7 +4,7 @@ import { patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSc
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { instanceSettingsService, logActivity } from "../services/index.js";
import { getActorInfo } from "./authz.js";
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
function assertCanManageInstanceSettings(req: Request) {
if (req.actor.type !== "board") {
@ -22,10 +22,8 @@ export function instanceSettingsRoutes(db: Db) {
router.get("/instance/settings/general", async (req, res) => {
// General settings (e.g. keyboardShortcuts) are readable by any
// authenticated board user. Only PATCH requires instance-admin.
if (req.actor.type !== "board") {
throw forbidden("Board access required");
}
// authenticated org member or instance admin. Only PATCH requires instance-admin.
assertBoardOrgAccess(req);
res.json(await svc.getGeneral());
});
@ -60,11 +58,9 @@ export function instanceSettingsRoutes(db: Db) {
);
router.get("/instance/settings/experimental", async (req, res) => {
// Experimental settings are readable by any authenticated board user.
// Only PATCH requires instance-admin.
if (req.actor.type !== "board") {
throw forbidden("Board access required");
}
// Experimental settings are readable by any authenticated org member
// or instance admin. Only PATCH requires instance-admin.
assertBoardOrgAccess(req);
res.json(await svc.getExperimental());
});

View file

@ -48,7 +48,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
/** UI slot declaration extracted from plugin manifest */
@ -372,7 +372,7 @@ export function pluginRoutes(
* Response: `PluginRecord[]`
*/
router.get("/plugins", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const rawStatus = req.query.status;
if (rawStatus !== undefined) {
if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) {
@ -396,7 +396,7 @@ export function pluginRoutes(
* These can be installed through the normal local-path install flow.
*/
router.get("/plugins/examples", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
res.json(listBundledPluginExamples());
});
@ -441,7 +441,7 @@ export function pluginRoutes(
* Response: PluginUiContribution[]
*/
router.get("/plugins/ui-contributions", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const plugins = await registry.listByStatus("ready");
const contributions: PluginUiContribution[] = plugins
@ -484,7 +484,7 @@ export function pluginRoutes(
* Errors: 501 if tool dispatcher is not configured
*/
router.get("/plugins/tools", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!toolDeps) {
res.status(501).json({ error: "Plugin tool dispatch is not enabled" });
@ -518,7 +518,7 @@ export function pluginRoutes(
* - 502 if the plugin worker is unavailable or the RPC call fails
*/
router.post("/plugins/tools/execute", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!toolDeps) {
res.status(501).json({ error: "Plugin tool dispatch is not enabled" });
@ -797,7 +797,7 @@ export function pluginRoutes(
* @see PLUGIN_SPEC.md §19.7 Error Propagation Through The Bridge
*/
router.post("/plugins/:pluginId/bridge/data", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!bridgeDeps) {
res.status(501).json({ error: "Plugin bridge is not enabled" });
@ -880,7 +880,7 @@ export function pluginRoutes(
* @see PLUGIN_SPEC.md §19.7 Error Propagation Through The Bridge
*/
router.post("/plugins/:pluginId/bridge/action", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!bridgeDeps) {
res.status(501).json({ error: "Plugin bridge is not enabled" });
@ -964,7 +964,7 @@ export function pluginRoutes(
* @see PLUGIN_SPEC.md §19.7 Error Propagation Through The Bridge
*/
router.post("/plugins/:pluginId/data/:key", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!bridgeDeps) {
res.status(501).json({ error: "Plugin bridge is not enabled" });
@ -1043,7 +1043,7 @@ export function pluginRoutes(
* @see PLUGIN_SPEC.md §19.7 Error Propagation Through The Bridge
*/
router.post("/plugins/:pluginId/actions/:key", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!bridgeDeps) {
res.status(501).json({ error: "Plugin bridge is not enabled" });
@ -1124,7 +1124,7 @@ export function pluginRoutes(
* - 501 if bridge deps or stream bus are not configured
*/
router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!bridgeDeps?.streamBus) {
res.status(501).json({ error: "Plugin stream bridge is not enabled" });
@ -1202,7 +1202,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found
*/
router.get("/plugins/:pluginId", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
@ -1232,7 +1232,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found, 400 for lifecycle errors
*/
router.delete("/plugins/:pluginId", async (req, res) => {
assertBoard(req);
assertInstanceAdmin(req);
const { pluginId } = req.params;
const purge = req.query.purge === "true";
@ -1268,7 +1268,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found, 400 for lifecycle errors
*/
router.post("/plugins/:pluginId/enable", async (req, res) => {
assertBoard(req);
assertInstanceAdmin(req);
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);
@ -1306,7 +1306,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found, 400 for lifecycle errors
*/
router.post("/plugins/:pluginId/disable", async (req, res) => {
assertBoard(req);
assertInstanceAdmin(req);
const { pluginId } = req.params;
const body = req.body as { reason?: string } | undefined;
const reason = body?.reason;
@ -1347,7 +1347,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found
*/
router.get("/plugins/:pluginId/health", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);
@ -1415,7 +1415,7 @@ export function pluginRoutes(
* Response: Array of log entries, newest first.
*/
router.get("/plugins/:pluginId/logs", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);
@ -1517,7 +1517,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found
*/
router.get("/plugins/:pluginId/config", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);
@ -1547,7 +1547,7 @@ export function pluginRoutes(
* - 404 if plugin not found
*/
router.post("/plugins/:pluginId/config", async (req, res) => {
assertBoard(req);
assertInstanceAdmin(req);
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);
@ -1652,7 +1652,7 @@ export function pluginRoutes(
* - 502 if the worker is unavailable
*/
router.post("/plugins/:pluginId/config/test", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!bridgeDeps) {
res.status(501).json({ error: "Plugin bridge is not enabled" });
@ -1749,7 +1749,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found
*/
router.get("/plugins/:pluginId/jobs", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!jobDeps) {
res.status(501).json({ error: "Job scheduling is not enabled" });
return;
@ -1795,7 +1795,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found
*/
router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!jobDeps) {
res.status(501).json({ error: "Job scheduling is not enabled" });
return;
@ -1843,7 +1843,7 @@ export function pluginRoutes(
* - 400 if job not found, not active, already running, or worker unavailable
*/
router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
if (!jobDeps) {
res.status(501).json({ error: "Job scheduling is not enabled" });
return;
@ -2049,7 +2049,7 @@ export function pluginRoutes(
* Errors: 404 if plugin not found
*/
router.get("/plugins/:pluginId/dashboard", async (req, res) => {
assertBoard(req);
assertBoardOrgAccess(req);
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);

View file

@ -5,6 +5,7 @@ import { inboxDismissals, joinRequests } from "@paperclipai/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { accessService } from "../services/access.js";
import { dashboardService } from "../services/dashboard.js";
import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-dedupe.js";
import { assertCompanyAccess } from "./authz.js";
function buildDismissedAtByKey(
@ -35,14 +36,24 @@ export function sidebarBadgeRoutes(db: Db) {
}
const visibleJoinRequests = canApproveJoins
? await db
.select({
id: joinRequests.id,
updatedAt: joinRequests.updatedAt,
createdAt: joinRequests.createdAt,
})
.from(joinRequests)
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
? collapseDuplicatePendingHumanJoinRequests(
await db
.select({
id: joinRequests.id,
requestType: joinRequests.requestType,
status: joinRequests.status,
requestingUserId: joinRequests.requestingUserId,
requestEmailSnapshot: joinRequests.requestEmailSnapshot,
updatedAt: joinRequests.updatedAt,
createdAt: joinRequests.createdAt,
})
.from(joinRequests)
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
).map(({ id, updatedAt, createdAt }) => ({
id,
updatedAt,
createdAt,
}))
: [];
const dismissedAtByKey =

View file

@ -6,6 +6,7 @@ import {
principalPermissionGrants,
} from "@paperclipai/db";
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
import { conflict } from "../errors.js";
type MembershipRow = typeof companyMemberships.$inferSelect;
type GrantInput = {
@ -83,6 +84,14 @@ export function accessService(db: Db) {
.orderBy(sql`${companyMemberships.createdAt} desc`);
}
async function getMemberById(companyId: string, memberId: string) {
return db
.select()
.from(companyMemberships)
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
.then((rows) => rows[0] ?? null);
}
async function listActiveUserMemberships(companyId: string) {
return db
.select()
@ -103,11 +112,7 @@ export function accessService(db: Db) {
grants: GrantInput[],
grantedByUserId: string | null,
) {
const member = await db
.select()
.from(companyMemberships)
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
.then((rows) => rows[0] ?? null);
const member = await getMemberById(companyId, memberId);
if (!member) return null;
await db.transaction(async (tx) => {
@ -139,6 +144,101 @@ export function accessService(db: Db) {
return member;
}
async function updateMemberAndPermissions(
companyId: string,
memberId: string,
data: {
membershipRole?: string | null;
status?: "pending" | "active" | "suspended";
grants: GrantInput[];
},
grantedByUserId: string | null,
) {
return db.transaction(async (tx) => {
await tx.execute(sql`
select ${companyMemberships.id}
from ${companyMemberships}
where ${companyMemberships.companyId} = ${companyId}
and ${companyMemberships.principalType} = 'user'
and ${companyMemberships.status} = 'active'
and ${companyMemberships.membershipRole} = 'owner'
for update
`);
const existing = await tx
.select()
.from(companyMemberships)
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
const nextMembershipRole =
data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole;
const nextStatus = data.status ?? existing.status;
if (
existing.principalType === "user" &&
existing.status === "active" &&
existing.membershipRole === "owner" &&
(nextStatus !== "active" || nextMembershipRole !== "owner")
) {
const activeOwnerCount = await tx
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
eq(companyMemberships.membershipRole, "owner"),
),
)
.then((rows) => rows.length);
if (activeOwnerCount <= 1) {
throw conflict("Cannot remove the last active owner");
}
}
const now = new Date();
const updated = await tx
.update(companyMemberships)
.set({
membershipRole: nextMembershipRole,
status: nextStatus,
updatedAt: now,
})
.where(eq(companyMemberships.id, existing.id))
.returning()
.then((rows) => rows[0] ?? existing);
await tx
.delete(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, existing.principalType),
eq(principalPermissionGrants.principalId, existing.principalId),
),
);
if (data.grants.length > 0) {
await tx.insert(principalPermissionGrants).values(
data.grants.map((grant) => ({
companyId,
principalType: existing.principalType,
principalId: existing.principalId,
permissionKey: grant.permissionKey,
scope: grant.scope ?? null,
grantedByUserId,
createdAt: now,
updatedAt: now,
})),
);
}
return updated;
});
}
async function promoteInstanceAdmin(userId: string) {
const existing = await db
.select()
@ -190,7 +290,7 @@ export function accessService(db: Db) {
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "member",
membershipRole: "operator",
});
}
});
@ -359,16 +459,84 @@ export function accessService(db: Db) {
});
}
async function updateMember(
companyId: string,
memberId: string,
data: {
membershipRole?: string | null;
status?: "pending" | "active" | "suspended";
},
) {
return db.transaction(async (tx) => {
await tx.execute(sql`
select ${companyMemberships.id}
from ${companyMemberships}
where ${companyMemberships.companyId} = ${companyId}
and ${companyMemberships.principalType} = 'user'
and ${companyMemberships.status} = 'active'
and ${companyMemberships.membershipRole} = 'owner'
for update
`);
const existing = await tx
.select()
.from(companyMemberships)
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
const nextMembershipRole =
data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole;
const nextStatus = data.status ?? existing.status;
if (
existing.principalType === "user" &&
existing.status === "active" &&
existing.membershipRole === "owner" &&
(nextStatus !== "active" || nextMembershipRole !== "owner")
) {
const activeOwnerCount = await tx
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
eq(companyMemberships.membershipRole, "owner"),
),
)
.then((rows) => rows.length);
if (activeOwnerCount <= 1) {
throw conflict("Cannot remove the last active owner");
}
}
return tx
.update(companyMemberships)
.set({
membershipRole: nextMembershipRole,
status: nextStatus,
updatedAt: new Date(),
})
.where(eq(companyMemberships.id, existing.id))
.returning()
.then((rows) => rows[0] ?? existing);
});
}
return {
isInstanceAdmin,
canUser,
hasPermission,
getMembership,
getMemberById,
ensureMembership,
listMembers,
listActiveUserMemberships,
copyActiveUserMemberships,
setMemberPermissions,
updateMemberAndPermissions,
promoteInstanceAdmin,
demoteInstanceAdmin,
listUserCompanyAccess,
@ -376,5 +544,6 @@ export function accessService(db: Db) {
setPrincipalGrants,
listPrincipalGrants,
setPrincipalPermission,
updateMember,
};
}

View file

@ -62,7 +62,11 @@ export function boardAuthService(db: Db) {
.where(eq(authUsers.id, userId))
.then((rows) => rows[0] ?? null),
db
.select({ companyId: companyMemberships.companyId })
.select({
companyId: companyMemberships.companyId,
membershipRole: companyMemberships.membershipRole,
status: companyMemberships.status,
})
.from(companyMemberships)
.where(
and(
@ -71,7 +75,7 @@ export function boardAuthService(db: Db) {
eq(companyMemberships.status, "active"),
),
)
.then((rows) => rows.map((row) => row.companyId)),
.then((rows) => rows),
db
.select({ id: instanceUserRoles.id })
.from(instanceUserRoles)
@ -81,7 +85,8 @@ export function boardAuthService(db: Db) {
return {
user,
companyIds: memberships,
companyIds: memberships.map((row) => row.companyId),
memberships,
isInstanceAdmin: Boolean(adminRole),
};
}

View file

@ -0,0 +1,59 @@
import { PERMISSION_KEYS } from "@paperclipai/shared";
import type { HumanCompanyMembershipRole } from "@paperclipai/shared";
const HUMAN_COMPANY_MEMBERSHIP_ROLES: HumanCompanyMembershipRole[] = [
"owner",
"admin",
"operator",
"viewer",
];
export function normalizeHumanRole(
value: unknown,
fallback: HumanCompanyMembershipRole = "operator"
): HumanCompanyMembershipRole {
if (value === "member") return "operator";
return HUMAN_COMPANY_MEMBERSHIP_ROLES.includes(value as HumanCompanyMembershipRole)
? (value as HumanCompanyMembershipRole)
: fallback;
}
export function grantsForHumanRole(
role: HumanCompanyMembershipRole
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
switch (role) {
case "owner":
return [
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
];
case "admin":
return [
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
];
case "operator":
return [{ permissionKey: "tasks:assign", scope: null }];
case "viewer":
return [];
}
}
export function resolveHumanInviteRole(
defaultsPayload: Record<string, unknown> | null | undefined
): HumanCompanyMembershipRole {
if (!defaultsPayload || typeof defaultsPayload !== "object") return "operator";
const scoped = defaultsPayload.human;
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
return "operator";
}
return normalizeHumanRole((scoped as Record<string, unknown>).role, "operator");
}

View file

@ -0,0 +1,68 @@
import { PERMISSION_KEYS } from "@paperclipai/shared";
import type { HumanCompanyMembershipRole } from "@paperclipai/shared";
import { grantsForHumanRole } from "./company-member-roles.js";
export function grantsFromDefaults(
defaultsPayload: Record<string, unknown> | null | undefined,
key: "human" | "agent"
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
if (!defaultsPayload || typeof defaultsPayload !== "object") return [];
const scoped = defaultsPayload[key];
if (!scoped || typeof scoped !== "object") return [];
const grants = (scoped as Record<string, unknown>).grants;
if (!Array.isArray(grants)) return [];
const validPermissionKeys = new Set<string>(PERMISSION_KEYS);
const result: Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> = [];
for (const item of grants) {
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
if (typeof record.permissionKey !== "string") continue;
if (!validPermissionKeys.has(record.permissionKey)) continue;
result.push({
permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number],
scope:
record.scope &&
typeof record.scope === "object" &&
!Array.isArray(record.scope)
? (record.scope as Record<string, unknown>)
: null,
});
}
return result;
}
export function agentJoinGrantsFromDefaults(
defaultsPayload: Record<string, unknown> | null | undefined
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
const grants = grantsFromDefaults(defaultsPayload, "agent");
if (grants.some((grant) => grant.permissionKey === "tasks:assign")) {
return grants;
}
return [
...grants,
{
permissionKey: "tasks:assign",
scope: null,
},
];
}
export function humanJoinGrantsFromDefaults(
defaultsPayload: Record<string, unknown> | null | undefined,
membershipRole: HumanCompanyMembershipRole
): Array<{
permissionKey: (typeof PERMISSION_KEYS)[number];
scope: Record<string, unknown> | null;
}> {
const grants = grantsFromDefaults(defaultsPayload, "human");
return grants.length > 0 ? grants : grantsForHumanRole(membershipRole);
}

View file

@ -6,9 +6,16 @@ declare global {
actor: {
type: "board" | "agent" | "none";
userId?: string;
userName?: string | null;
userEmail?: string | null;
agentId?: string;
companyId?: string;
companyIds?: string[];
memberships?: Array<{
companyId: string;
membershipRole?: string | null;
status?: string;
}>;
isInstanceAdmin?: boolean;
keyId?: string;
runId?: string;

View file

@ -118,11 +118,19 @@ function resolveWorktreeRuntimeContext(
const configPath = resolvePaperclipConfigPath(overrideConfigPath);
const envPath = resolvePaperclipEnvPath(configPath);
const persistedEnv = readEnvEntries(envPath);
const worktreeRoot = path.resolve(path.dirname(configPath), "..");
const worktreeName = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot);
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName);
const worktreeName =
nonEmpty(persistedEnv.PAPERCLIP_WORKTREE_NAME) ??
nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ??
path.basename(worktreeRoot);
const instanceId =
nonEmpty(persistedEnv.PAPERCLIP_INSTANCE_ID) ??
nonEmpty(env.PAPERCLIP_INSTANCE_ID) ??
sanitizeWorktreeInstanceId(worktreeName);
const homeDir = resolveHomeAwarePath(
nonEmpty(env.PAPERCLIP_HOME) ??
nonEmpty(persistedEnv.PAPERCLIP_HOME) ??
nonEmpty(env.PAPERCLIP_HOME) ??
nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ??
"~/.paperclip-worktrees",
);