mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
parent
e93e418cbf
commit
b9a80dcf22
150 changed files with 26872 additions and 1289 deletions
99
server/src/__tests__/access-service.test.ts
Normal file
99
server/src/__tests__/access-service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
33
server/src/__tests__/access-validators.test.ts
Normal file
33
server/src/__tests__/access-validators.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
170
server/src/__tests__/auth-routes.test.ts
Normal file
170
server/src/__tests__/auth-routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
61
server/src/__tests__/auth-session-route.test.ts
Normal file
61
server/src/__tests__/auth-session-route.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
157
server/src/__tests__/authz-company-access.test.ts
Normal file
157
server/src/__tests__/authz-company-access.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
132
server/src/__tests__/company-user-directory-route.test.ts
Normal file
132
server/src/__tests__/company-user-directory-route.test.ts
Normal 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" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
126
server/src/__tests__/invite-accept-existing-member.test.ts
Normal file
126
server/src/__tests__/invite-accept-existing-member.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
126
server/src/__tests__/invite-create-route.test.ts
Normal file
126
server/src/__tests__/invite-create-route.test.ts
Normal 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_/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
164
server/src/__tests__/invite-list-route.test.ts
Normal file
164
server/src/__tests__/invite-list-route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
146
server/src/__tests__/invite-logo-route.test.ts
Normal file
146
server/src/__tests__/invite-logo-route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
270
server/src/__tests__/invite-summary-route.test.ts
Normal file
270
server/src/__tests__/invite-summary-route.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
104
server/src/__tests__/join-request-dedupe.test.ts
Normal file
104
server/src/__tests__/join-request-dedupe.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
73
server/src/__tests__/shared-telemetry-events.test.ts
Normal file
73
server/src/__tests__/shared-telemetry-events.test.ts
Normal 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) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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?: (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
88
server/src/lib/join-request-dedupe.ts
Normal file
88
server/src/lib/join-request-dedupe.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
100
server/src/routes/auth.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
59
server/src/services/company-member-roles.ts
Normal file
59
server/src/services/company-member-roles.ts
Normal 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");
|
||||
}
|
||||
68
server/src/services/invite-grants.ts
Normal file
68
server/src/services/invite-grants.ts
Normal 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);
|
||||
}
|
||||
7
server/src/types/express.d.ts
vendored
7
server/src/types/express.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue