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

## Thinking Path

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

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

---------

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

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
node_modules
node_modules/
**/node_modules
**/node_modules/
dist/
.env
*.tsbuildinfo

View File

@@ -12,7 +12,7 @@
<p align="center">
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
</p>
<br/>

View File

@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
agents,
authUsers,
companies,
createDb,
projects,
@@ -47,6 +48,7 @@ import {
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_ENV = { ...process.env };
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip;
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -373,6 +375,97 @@ describe("worktree helpers", () => {
}
});
itEmbeddedPostgres(
"seeds authenticated users into minimally cloned worktree instances",
async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-"));
const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed");
const sourceHome = path.join(tempRoot, "source-home");
const sourceConfigDir = path.join(sourceHome, "instances", "source");
const sourceConfigPath = path.join(sourceConfigDir, "config.json");
const sourceEnvPath = path.join(sourceConfigDir, ".env");
const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key");
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
const originalCwd = process.cwd();
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
try {
const sourceDbClient = createDb(sourceDb.connectionString);
await sourceDbClient.insert(authUsers).values({
id: "user-existing",
email: "existing@paperclip.ing",
name: "Existing User",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
});
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
fs.mkdirSync(worktreeRoot, { recursive: true });
const sourceConfig = buildSourceConfig();
sourceConfig.database = {
mode: "postgres",
embeddedPostgresDataDir: path.join(sourceConfigDir, "db"),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(sourceConfigDir, "backups"),
},
connectionString: sourceDb.connectionString,
};
sourceConfig.logging.logDir = path.join(sourceConfigDir, "logs");
sourceConfig.storage.localDisk.baseDir = path.join(sourceConfigDir, "storage");
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
fs.writeFileSync(sourceConfigPath, JSON.stringify(sourceConfig, null, 2) + "\n", "utf8");
fs.writeFileSync(sourceEnvPath, "", "utf8");
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
process.chdir(worktreeRoot);
await worktreeInitCommand({
name: "PAP-999-auth-seed",
home: worktreeHome,
fromConfig: sourceConfigPath,
force: true,
});
const targetConfig = JSON.parse(
fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"),
) as PaperclipConfig;
const { default: EmbeddedPostgres } = await import("embedded-postgres");
const targetPg = new EmbeddedPostgres({
databaseDir: targetConfig.database.embeddedPostgresDataDir,
user: "paperclip",
password: "paperclip",
port: targetConfig.database.embeddedPostgresPort,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: () => {},
onError: () => {},
});
await targetPg.start();
try {
const targetDb = createDb(
`postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`,
);
const seededUsers = await targetDb.select().from(authUsers);
expect(seededUsers.some((row) => row.email === "existing@paperclip.ing")).toBe(true);
} finally {
await targetPg.stop();
}
} finally {
process.chdir(originalCwd);
await sourceDb.cleanup();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
},
20000,
);
it("avoids ports already claimed by sibling worktree instance configs", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
const repoRoot = path.join(tempRoot, "repo");

View File

@@ -142,3 +142,4 @@ This prevents lockout when a user migrates from long-running local trusted usage
- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md`
- V1 contract: `doc/SPEC-implementation.md`
- operator workflows: `doc/DEVELOPING.md` and `doc/CLI.md`
- invite/join state map: `doc/spec/invite-flow.md`

View File

@@ -115,38 +115,6 @@ If the first real publish returns npm `E404`, check npm-side prerequisites befor
- The initial publish must include `--access public` for a public scoped package.
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
### Manual first publish for `@paperclipai/mcp-server`
If you need to publish only the MCP server package once by hand, use:
- `@paperclipai/mcp-server`
Recommended flow from the repo root:
```bash
# optional sanity check: this 404s until the first publish exists
npm view @paperclipai/mcp-server version
# make sure the build output is fresh
pnpm --filter @paperclipai/mcp-server build
# confirm your local npm auth before the real publish
npm whoami
# safe preview of the exact publish payload
cd packages/mcp-server
pnpm publish --dry-run --no-git-checks --access public
# real publish
pnpm publish --no-git-checks --access public
```
Notes:
- Publish from `packages/mcp-server/`, not the repo root.
- If `npm view @paperclipai/mcp-server version` already returns the same version that is in [`packages/mcp-server/package.json`](../packages/mcp-server/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh).
- The same npm-side prerequisites apply as above: valid npm auth, permission to publish to the `@paperclipai` scope, `--access public`, and the required publish auth/2FA policy.
## Version formats
Paperclip uses calendar versions:

299
doc/spec/invite-flow.md Normal file
View File

@@ -0,0 +1,299 @@
# Invite Flow State Map
Status: Current implementation map
Date: 2026-04-13
This document maps the current invite creation and acceptance states implemented in:
- `ui/src/pages/CompanyInvites.tsx`
- `ui/src/pages/CompanySettings.tsx`
- `ui/src/pages/InviteLanding.tsx`
- `server/src/routes/access.ts`
- `server/src/lib/join-request-dedupe.ts`
## State Legend
- Invite state: `active`, `revoked`, `accepted`, `expired`
- Join request status: `pending_approval`, `approved`, `rejected`
- Claim secret state for agent joins: `available`, `consumed`, `expired`
- Invite type: `company_join` or `bootstrap_ceo`
- Join type: `human`, `agent`, or `both`
## Entity Lifecycle
```mermaid
flowchart TD
Board[Board user on invite screen]
HumanInvite[Create human company invite]
OpenClawInvite[Generate OpenClaw invite prompt]
Active[Invite state: active]
Revoked[Invite state: revoked]
Expired[Invite state: expired]
Accepted[Invite state: accepted]
BootstrapDone[Bootstrap accepted<br/>no join request]
HumanReuse{Matching human join request<br/>already exists for same user/email?}
HumanPending[Join request<br/>pending_approval]
HumanApproved[Join request<br/>approved]
HumanRejected[Join request<br/>rejected]
AgentPending[Agent join request<br/>pending_approval<br/>+ optional claim secret]
AgentApproved[Agent join request<br/>approved]
AgentRejected[Agent join request<br/>rejected]
ClaimAvailable[Claim secret available]
ClaimConsumed[Claim secret consumed]
ClaimExpired[Claim secret expired]
OpenClawReplay[Special replay path:<br/>accepted invite can be POSTed again<br/>for openclaw_gateway only]
Board --> HumanInvite --> Active
Board --> OpenClawInvite --> Active
Active --> Revoked: revoke
Active --> Expired: expiresAt passes
Active --> BootstrapDone: bootstrap_ceo accept
BootstrapDone --> Accepted
Active --> HumanReuse: human accept
HumanReuse --> HumanPending: reuse existing pending request
HumanReuse --> HumanApproved: reuse existing approved request
HumanReuse --> HumanPending: no reusable request<br/>create new request
HumanPending --> HumanApproved: board approves
HumanPending --> HumanRejected: board rejects
HumanPending --> Accepted
HumanApproved --> Accepted
Active --> AgentPending: agent accept
AgentPending --> Accepted
AgentPending --> AgentApproved: board approves
AgentPending --> AgentRejected: board rejects
AgentApproved --> ClaimAvailable: createdAgentId + claimSecretHash
ClaimAvailable --> ClaimConsumed: POST claim-api-key succeeds
ClaimAvailable --> ClaimExpired: secret expires
Accepted --> OpenClawReplay
OpenClawReplay --> AgentPending
OpenClawReplay --> AgentApproved
```
## Board-Side Screen States
```mermaid
stateDiagram-v2
[*] --> CompanySelection
CompanySelection --> NoCompany: no company selected
CompanySelection --> LoadingHistory: selectedCompanyId present
LoadingHistory --> HistoryError: listInvites failed
LoadingHistory --> Ready: listInvites succeeded
state Ready {
[*] --> EmptyHistory
EmptyHistory --> PopulatedHistory: invites exist
PopulatedHistory --> LoadingMore: View more
LoadingMore --> PopulatedHistory: next page loaded
PopulatedHistory --> RevokePending: Revoke active invite
RevokePending --> PopulatedHistory: revoke succeeded
RevokePending --> PopulatedHistory: revoke failed
EmptyHistory --> CreatePending: Create invite
PopulatedHistory --> CreatePending: Create invite
CreatePending --> LatestInviteVisible: create succeeded
CreatePending --> Ready: create failed
LatestInviteVisible --> CopiedToast: clipboard copy succeeded
LatestInviteVisible --> Ready: navigate away or refresh
}
CompanySelection --> OpenClawPromptReady: Company settings prompt generator
OpenClawPromptReady --> OpenClawPromptPending: Generate OpenClaw Invite Prompt
OpenClawPromptPending --> OpenClawSnippetVisible: prompt generated
OpenClawPromptPending --> OpenClawPromptReady: generation failed
```
## Invite Landing Screen States
```mermaid
stateDiagram-v2
[*] --> TokenGate
TokenGate --> InvalidToken: token missing
TokenGate --> Loading: token present
Loading --> InviteUnavailable: invite fetch failed or invite not returned
Loading --> CheckingAccess: signed-in session and invite.companyId
Loading --> InviteResolved: invite loaded without membership check
Loading --> AcceptedInviteSummary: invite already consumed<br/>but linked join request still exists
CheckingAccess --> RedirectToBoard: current user already belongs to company
CheckingAccess --> InviteResolved: membership check finished and no join-request summary state is active
CheckingAccess --> AcceptedInviteSummary: membership check finished and invite has joinRequestStatus
state InviteResolved {
[*] --> Branch
Branch --> AgentForm: company_join + allowedJoinTypes=agent
Branch --> InlineAuth: authenticated mode + no session + join is not agent-only
Branch --> AcceptReady: bootstrap invite or human-ready session/local_trusted
InlineAuth --> InlineAuth: toggle sign-up/sign-in
InlineAuth --> InlineAuth: auth validation or auth error message
InlineAuth --> RedirectToBoard: auth succeeded and company membership already exists
InlineAuth --> AcceptPending: auth succeeded and invite still needs acceptance
AgentForm --> AcceptPending: submit request
AgentForm --> AgentForm: validation or accept error
AcceptReady --> AcceptPending: Accept invite
AcceptReady --> AcceptReady: accept error
}
AcceptPending --> BootstrapComplete: bootstrapAccepted=true
AcceptPending --> RedirectToBoard: join status=approved
AcceptPending --> PendingApprovalResult: join status=pending_approval
AcceptPending --> RejectedResult: join status=rejected
state AcceptedInviteSummary {
[*] --> SummaryBranch
SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval
SummaryBranch --> OpeningCompany: joinRequestStatus=approved<br/>and human invite user is now a member
SummaryBranch --> RejectedReload: joinRequestStatus=rejected
SummaryBranch --> ConsumedReload: approved agent invite or other consumed state
}
PendingApprovalResult --> PendingApprovalReload: reload after submit
RejectedResult --> RejectedReload: reload after board rejects
RedirectToBoard --> OpeningCompany: brief pre-navigation render when approved membership is detected
OpeningCompany --> RedirectToBoard: navigate to board
```
## Sequence Diagrams
### Human Invite Creation And First Acceptance
```mermaid
sequenceDiagram
autonumber
actor Board as Board user
participant Settings as Company Invites UI
participant API as Access routes
participant Invites as invites table
actor Invitee as Invite recipient
participant Landing as Invite landing UI
participant Auth as Auth session
participant Join as join_requests table
Board->>Settings: Choose role and click Create invite
Settings->>API: POST /api/companies/:companyId/invites
API->>Invites: Insert active invite
API-->>Settings: inviteUrl + metadata
Invitee->>Landing: Open invite URL
Landing->>API: GET /api/invites/:token
API->>Invites: Load active invite
API-->>Landing: Invite summary
alt Authenticated mode and no session
Landing->>Auth: Sign up or sign in
Auth-->>Landing: Session established
end
Landing->>API: POST /api/invites/:token/accept (requestType=human)
API->>Join: Look for reusable human join request
alt Reusable pending or approved request exists
API->>Invites: Mark invite accepted
API-->>Landing: Existing join request status
else No reusable request exists
API->>Invites: Mark invite accepted
API->>Join: Insert pending_approval join request
API-->>Landing: New pending_approval join request
end
```
### Human Approval And Reload Path
```mermaid
sequenceDiagram
autonumber
actor Invitee as Invite recipient
participant Landing as Invite landing UI
participant API as Access routes
participant Join as join_requests table
actor Approver as Company admin
participant Queue as Access queue UI
participant Membership as company_memberships + grants
Invitee->>Landing: Reload consumed invite URL
Landing->>API: GET /api/invites/:token
API->>Join: Load join request by inviteId
API-->>Landing: joinRequestStatus + joinRequestType
alt joinRequestStatus = pending_approval
Landing-->>Invitee: Show waiting-for-approval panel
Approver->>Queue: Review request in Company Settings -> Access
Queue->>API: POST /companies/:companyId/join-requests/:requestId/approve
API->>Membership: Ensure membership and grants
API->>Join: Mark join request approved
Invitee->>Landing: Refresh after approval
Landing->>API: GET /api/invites/:token
API->>Join: Reload approved join request
API-->>Landing: approved status
Landing-->>Invitee: Opening company and redirect
else joinRequestStatus = rejected
Landing-->>Invitee: Show rejected error panel
else joinRequestStatus = approved but membership missing
Landing-->>Invitee: Fall through to consumed/unavailable state
end
```
### Agent Invite Approval, Claim, And Replay
```mermaid
sequenceDiagram
autonumber
actor Board as Board user
participant Settings as Company Settings UI
participant API as Access routes
participant Invites as invites table
actor Gateway as OpenClaw gateway agent
participant Join as join_requests table
actor Approver as Company admin
participant Agents as agents table
participant Keys as agent_api_keys table
Board->>Settings: Generate OpenClaw invite prompt
Settings->>API: POST /api/companies/:companyId/openclaw-invite-prompt
API->>Invites: Insert active agent invite
API-->>Settings: Prompt text + invite token
Gateway->>API: POST /api/invites/:token/accept (agent, openclaw_gateway)
API->>Invites: Mark invite accepted
API->>Join: Insert pending_approval join request + claimSecretHash
API-->>Gateway: requestId + claimSecret + claimApiKeyPath
Approver->>API: POST /companies/:companyId/join-requests/:requestId/approve
API->>Agents: Create agent + membership + grants
API->>Join: Mark request approved and store createdAgentId
Gateway->>API: POST /api/join-requests/:requestId/claim-api-key (claimSecret)
API->>Keys: Create initial API key
API->>Join: Mark claim secret consumed
API-->>Gateway: Plaintext Paperclip API key
opt Replay accepted invite for updated gateway defaults
Gateway->>API: POST /api/invites/:token/accept again
API->>Join: Reuse existing approved or pending request
API->>Agents: Update approved agent adapter config when applicable
API-->>Gateway: Updated join request payload
end
```
## Notes
- `GET /api/invites/:token` treats `revoked` and `expired` invites as unavailable. Accepted invites remain resolvable when they already have a linked join request, and the summary now includes `joinRequestStatus` plus `joinRequestType`.
- Human acceptance consumes the invite immediately and then either creates a new join request or reuses an existing `pending_approval` or `approved` human join request for the same user/email.
- The landing page has two layers of post-accept UI:
- immediate mutation-result UI from `POST /api/invites/:token/accept`
- reload-time summary UI from `GET /api/invites/:token` once the invite has already been consumed
- Reload behavior for accepted company invites is now status-sensitive:
- `pending_approval` re-renders the waiting-for-approval panel
- `rejected` renders the "This join request was not approved." error panel
- `approved` only becomes a success path for human invites after membership is visible to the current session; otherwise the page falls through to the generic consumed/unavailable state
- `GET /api/invites/:token/logo` still rejects accepted invites, so accepted-invite reload states may fall back to the generated company icon even though the summary payload still carries `companyLogoUrl`.
- The only accepted-invite replay path in the current implementation is `POST /api/invites/:token/accept` for `agent` requests with `adapterType=openclaw_gateway`, and only when the existing join request is still `pending_approval` or already `approved`.
- `bootstrap_ceo` invites are one-time and do not create join requests.

View File

@@ -48,6 +48,8 @@
"guides/board-operator/managing-tasks",
"guides/board-operator/execution-workspaces-and-runtime-services",
"guides/board-operator/delegation",
"guides/board-operator/execution-workspaces-and-runtime-services",
"guides/board-operator/delegation",
"guides/board-operator/approvals",
"guides/board-operator/costs-and-budgets",
"guides/board-operator/activity-log",

View File

@@ -34,6 +34,7 @@
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",
"evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval",
"test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts",
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed",

View File

@@ -0,0 +1,84 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb } from "../src/client.js";
import { invites } from "../src/schema/index.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function createInviteToken() {
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
}
function readArg(flag: string) {
const index = process.argv.indexOf(flag);
if (index === -1) return null;
return process.argv[index + 1] ?? null;
}
async function main() {
const configPath = readArg("--config");
const baseUrl = readArg("--base-url");
if (!configPath || !baseUrl) {
throw new Error("Usage: tsx create-auth-bootstrap-invite.ts --config <path> --base-url <url>");
}
const config = JSON.parse(readFileSync(path.resolve(configPath), "utf8")) as {
database?: {
mode?: string;
embeddedPostgresPort?: number;
connectionString?: string;
};
};
const dbUrl =
config.database?.mode === "postgres"
? config.database.connectionString
: `postgres://paperclip:paperclip@127.0.0.1:${config.database?.embeddedPostgresPort ?? 54329}/paperclip`;
if (!dbUrl) {
throw new Error(`Could not resolve database connection from ${configPath}`);
}
const db = createDb(dbUrl);
const closableDb = db as typeof db & {
$client?: {
end?: (options?: { timeout?: number }) => Promise<void>;
};
};
try {
const now = new Date();
await db
.update(invites)
.set({ revokedAt: now, updatedAt: now })
.where(
and(
eq(invites.inviteType, "bootstrap_ceo"),
isNull(invites.revokedAt),
isNull(invites.acceptedAt),
gt(invites.expiresAt, now)
)
);
const token = createInviteToken();
await db.insert(invites).values({
inviteType: "bootstrap_ceo",
tokenHash: hashToken(token),
allowedJoinTypes: "human",
expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
invitedByUserId: "system",
});
process.stdout.write(`${baseUrl.replace(/\/+$/, "")}/invite/${token}\n`);
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,57 @@
WITH ranked_user_requests AS (
SELECT
id,
row_number() OVER (
PARTITION BY company_id, requesting_user_id
ORDER BY created_at ASC, id ASC
) AS rank
FROM join_requests
WHERE request_type = 'human'
AND status = 'pending_approval'
AND requesting_user_id IS NOT NULL
)
UPDATE join_requests
SET
status = 'rejected',
rejected_at = COALESCE(rejected_at, now()),
updated_at = now()
WHERE id IN (
SELECT id
FROM ranked_user_requests
WHERE rank > 1
);
--> statement-breakpoint
WITH ranked_email_requests AS (
SELECT
id,
row_number() OVER (
PARTITION BY company_id, lower(request_email_snapshot)
ORDER BY created_at ASC, id ASC
) AS rank
FROM join_requests
WHERE request_type = 'human'
AND status = 'pending_approval'
AND request_email_snapshot IS NOT NULL
)
UPDATE join_requests
SET
status = 'rejected',
rejected_at = COALESCE(rejected_at, now()),
updated_at = now()
WHERE id IN (
SELECT id
FROM ranked_email_requests
WHERE rank > 1
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "join_requests_pending_human_user_uq"
ON "join_requests" USING btree ("company_id", "requesting_user_id")
WHERE "request_type" = 'human'
AND "status" = 'pending_approval'
AND "requesting_user_id" IS NOT NULL;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "join_requests_pending_human_email_uq"
ON "join_requests" USING btree ("company_id", lower("request_email_snapshot"))
WHERE "request_type" = 'human'
AND "status" = 'pending_approval'
AND "request_email_snapshot" IS NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -400,6 +400,13 @@
"when": 1776084034244,
"tag": "0056_spooky_ultragirl",
"breakpoints": true
},
{
"idx": 57,
"version": "7",
"when": 1776309613598,
"tag": "0057_tidy_join_requests",
"breakpoints": true
}
]
}
}

View File

@@ -1,3 +1,4 @@
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { invites } from "./invites.js";
@@ -37,5 +38,11 @@ export const joinRequests = pgTable(
table.requestType,
table.createdAt,
),
pendingHumanUserUniqueIdx: uniqueIndex("join_requests_pending_human_user_uq")
.on(table.companyId, table.requestingUserId)
.where(sql`${table.requestType} = 'human' AND ${table.status} = 'pending_approval' AND ${table.requestingUserId} IS NOT NULL`),
pendingHumanEmailUniqueIdx: uniqueIndex("join_requests_pending_human_email_uq")
.on(table.companyId, sql`lower(${table.requestEmailSnapshot})`)
.where(sql`${table.requestType} = 'human' AND ${table.status} = 'pending_approval' AND ${table.requestEmailSnapshot} IS NOT NULL`),
}),
);

View File

@@ -362,6 +362,30 @@ export type PrincipalType = (typeof PRINCIPAL_TYPES)[number];
export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const;
export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number];
export const COMPANY_MEMBERSHIP_ROLES = [
"owner",
"admin",
"operator",
"viewer",
"member",
] as const;
export type CompanyMembershipRole = (typeof COMPANY_MEMBERSHIP_ROLES)[number];
export const HUMAN_COMPANY_MEMBERSHIP_ROLES = [
"owner",
"admin",
"operator",
"viewer",
] as const;
export type HumanCompanyMembershipRole = (typeof HUMAN_COMPANY_MEMBERSHIP_ROLES)[number];
export const HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS: Record<HumanCompanyMembershipRole, string> = {
owner: "Owner",
admin: "Admin",
operator: "Operator",
viewer: "Viewer",
};
export const INSTANCE_USER_ROLES = ["instance_admin"] as const;
export type InstanceUserRole = (typeof INSTANCE_USER_ROLES)[number];

View File

@@ -54,6 +54,9 @@ export {
LIVE_EVENT_TYPES,
PRINCIPAL_TYPES,
MEMBERSHIP_STATUSES,
COMPANY_MEMBERSHIP_ROLES,
HUMAN_COMPANY_MEMBERSHIP_ROLES,
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
INSTANCE_USER_ROLES,
INVITE_TYPES,
INVITE_JOIN_TYPES,
@@ -127,6 +130,8 @@ export {
type LiveEventType,
type PrincipalType,
type MembershipStatus,
type CompanyMembershipRole,
type HumanCompanyMembershipRole,
type InstanceUserRole,
type InviteType,
type InviteJoinType,
@@ -308,11 +313,21 @@ export type {
SidebarBadges,
SidebarOrderPreference,
InboxDismissal,
AccessUserProfile,
CompanyMemberRecord,
CompanyMembersResponse,
CompanyMembership,
CompanyInviteListResponse,
CompanyInviteRecord,
PrincipalPermissionGrant,
Invite,
JoinRequest,
JoinRequestInviteSummary,
JoinRequestRecord,
InstanceUserRoleGrant,
AdminUserDirectoryEntry,
UserCompanyAccessEntry,
UserCompanyAccessResponse,
CompanyPortabilityInclude,
CompanyPortabilityEnvInput,
CompanyPortabilityFileEntry,
@@ -566,12 +581,19 @@ export {
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
acceptInviteSchema,
listCompanyInvitesQuerySchema,
listJoinRequestsQuerySchema,
claimJoinRequestApiKeySchema,
boardCliAuthAccessLevelSchema,
createCliAuthChallengeSchema,
resolveCliAuthChallengeSchema,
currentUserProfileSchema,
authSessionSchema,
updateCurrentUserProfileSchema,
updateCompanyMemberSchema,
updateCompanyMemberWithPermissionsSchema,
updateMemberPermissionsSchema,
searchAdminUsersQuerySchema,
updateUserCompanyAccessSchema,
type CreateCostEvent,
type CreateFinanceEvent,
@@ -580,12 +602,19 @@ export {
type CreateCompanyInvite,
type CreateOpenClawInvitePrompt,
type AcceptInvite,
type ListCompanyInvitesQuery,
type ListJoinRequestsQuery,
type ClaimJoinRequestApiKey,
type BoardCliAuthAccessLevel,
type CreateCliAuthChallenge,
type ResolveCliAuthChallenge,
type CurrentUserProfile,
type AuthSession,
type UpdateCurrentUserProfile,
type UpdateCompanyMember,
type UpdateCompanyMemberWithPermissions,
type UpdateMemberPermissions,
type SearchAdminUsersQuery,
type UpdateUserCompanyAccess,
companySkillSourceTypeSchema,
companySkillTrustLevelSchema,
@@ -663,18 +692,23 @@ export {
AGENT_MENTION_SCHEME,
PROJECT_MENTION_SCHEME,
SKILL_MENTION_SCHEME,
USER_MENTION_SCHEME,
buildAgentMentionHref,
buildProjectMentionHref,
buildSkillMentionHref,
buildUserMentionHref,
extractAgentMentionIds,
extractProjectMentionIds,
extractSkillMentionIds,
extractUserMentionIds,
parseAgentMentionHref,
parseProjectMentionHref,
parseSkillMentionHref,
extractProjectMentionIds,
parseUserMentionHref,
type ParsedAgentMention,
type ParsedProjectMention,
type ParsedSkillMention,
type ParsedUserMention,
} from "./project-mentions.js";
export {

View File

@@ -3,12 +3,15 @@ import {
buildAgentMentionHref,
buildProjectMentionHref,
buildSkillMentionHref,
buildUserMentionHref,
extractAgentMentionIds,
extractProjectMentionIds,
extractSkillMentionIds,
extractUserMentionIds,
parseAgentMentionHref,
parseProjectMentionHref,
parseSkillMentionHref,
parseUserMentionHref,
} from "./project-mentions.js";
describe("project-mentions", () => {
@@ -30,6 +33,14 @@ describe("project-mentions", () => {
expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]);
});
it("round-trips user mentions", () => {
const href = buildUserMentionHref("user-123");
expect(parseUserMentionHref(href)).toEqual({
userId: "user-123",
});
expect(extractUserMentionIds(`[@Taylor](${href})`)).toEqual(["user-123"]);
});
it("round-trips skill mentions with slug metadata", () => {
const href = buildSkillMentionHref("skill-123", "release-changelog");
expect(parseSkillMentionHref(href)).toEqual({

View File

@@ -1,5 +1,6 @@
export const PROJECT_MENTION_SCHEME = "project://";
export const AGENT_MENTION_SCHEME = "agent://";
export const USER_MENTION_SCHEME = "user://";
export const SKILL_MENTION_SCHEME = "skill://";
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
@@ -8,6 +9,7 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i;
const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi;
const USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\s]+)\)/gi;
const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi;
const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i;
const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i;
@@ -22,6 +24,10 @@ export interface ParsedAgentMention {
icon: string | null;
}
export interface ParsedUserMention {
userId: string;
}
export interface ParsedSkillMention {
skillId: string;
slug: string | null;
@@ -111,6 +117,28 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null {
};
}
export function buildUserMentionHref(userId: string): string {
return `${USER_MENTION_SCHEME}${userId.trim()}`;
}
export function parseUserMentionHref(href: string): ParsedUserMention | null {
if (!href.startsWith(USER_MENTION_SCHEME)) return null;
let url: URL;
try {
url = new URL(href);
} catch {
return null;
}
if (url.protocol !== "user:") return null;
const userId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim();
if (!userId) return null;
return { userId };
}
export function buildSkillMentionHref(skillId: string, slug?: string | null): string {
const trimmedSkillId = skillId.trim();
const normalizedSlug = normalizeSkillSlug(slug ?? null);
@@ -165,6 +193,18 @@ export function extractAgentMentionIds(markdown: string): string[] {
return [...ids];
}
export function extractUserMentionIds(markdown: string): string[] {
if (!markdown) return [];
const ids = new Set<string>();
const re = new RegExp(USER_MENTION_LINK_RE);
let match: RegExpExecArray | null;
while ((match = re.exec(markdown)) !== null) {
const parsed = parseUserMentionHref(match[1]);
if (parsed) ids.add(parsed.userId);
}
return [...ids];
}
export function extractSkillMentionIds(markdown: string): string[] {
if (!markdown) return [];
const ids = new Set<string>();

View File

@@ -1,5 +1,7 @@
import type {
AgentAdapterType,
CompanyStatus,
HumanCompanyMembershipRole,
InstanceUserRole,
InviteJoinType,
InviteType,
@@ -33,6 +35,30 @@ export interface PrincipalPermissionGrant {
updatedAt: Date;
}
export interface AccessUserProfile {
id: string;
email: string | null;
name: string | null;
image: string | null;
}
export interface CompanyMemberRecord extends CompanyMembership {
principalType: "user";
membershipRole: HumanCompanyMembershipRole | null;
user: AccessUserProfile | null;
grants: PrincipalPermissionGrant[];
}
export interface CompanyMembersResponse {
members: CompanyMemberRecord[];
access: {
currentUserRole: HumanCompanyMembershipRole | null;
canManageMembers: boolean;
canInviteUsers: boolean;
canApproveJoinRequests: boolean;
};
}
export interface Invite {
id: string;
companyId: string | null;
@@ -48,6 +74,22 @@ export interface Invite {
updatedAt: Date;
}
export type InviteState = "active" | "revoked" | "accepted" | "expired";
export interface CompanyInviteRecord extends Invite {
companyName: string | null;
humanRole: HumanCompanyMembershipRole | null;
inviteMessage: string | null;
state: InviteState;
invitedByUser: AccessUserProfile | null;
relatedJoinRequestId: string | null;
}
export interface CompanyInviteListResponse {
invites: CompanyInviteRecord[];
nextOffset: number | null;
}
export interface JoinRequest {
id: string;
inviteId: string;
@@ -72,6 +114,26 @@ export interface JoinRequest {
updatedAt: Date;
}
export interface JoinRequestInviteSummary {
id: string;
inviteType: InviteType;
allowedJoinTypes: InviteJoinType;
humanRole: HumanCompanyMembershipRole | null;
inviteMessage: string | null;
createdAt: Date;
expiresAt: Date;
revokedAt: Date | null;
acceptedAt: Date | null;
invitedByUser: AccessUserProfile | null;
}
export interface JoinRequestRecord extends JoinRequest {
requesterUser: AccessUserProfile | null;
approvedByUser: AccessUserProfile | null;
rejectedByUser: AccessUserProfile | null;
invite: JoinRequestInviteSummary | null;
}
export interface InstanceUserRoleGrant {
id: string;
userId: string;
@@ -79,3 +141,21 @@ export interface InstanceUserRoleGrant {
createdAt: Date;
updatedAt: Date;
}
export interface AdminUserDirectoryEntry extends AccessUserProfile {
isInstanceAdmin: boolean;
activeCompanyMembershipCount: number;
}
export interface UserCompanyAccessEntry extends CompanyMembership {
principalType: "user";
companyName: string | null;
companyStatus: CompanyStatus | null;
}
export interface UserCompanyAccessResponse {
user: (AccessUserProfile & {
isInstanceAdmin: boolean;
}) | null;
companyAccess: UserCompanyAccessEntry[];
}

View File

@@ -173,11 +173,21 @@ export type { SidebarBadges } from "./sidebar-badges.js";
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
export type { InboxDismissal } from "./inbox-dismissal.js";
export type {
AccessUserProfile,
CompanyMemberRecord,
CompanyMembersResponse,
CompanyMembership,
CompanyInviteListResponse,
CompanyInviteRecord,
PrincipalPermissionGrant,
Invite,
JoinRequest,
JoinRequestInviteSummary,
JoinRequestRecord,
InstanceUserRoleGrant,
AdminUserDirectoryEntry,
UserCompanyAccessEntry,
UserCompanyAccessResponse,
} from "./access.js";
export type { QuotaWindow, ProviderQuotaResult } from "./quota.js";
export type {

View File

@@ -1,10 +1,10 @@
import type { PauseReason, ProjectStatus } from "../constants.js";
import type { AgentEnvConfig } from "./secrets.js";
import type {
ProjectExecutionWorkspacePolicy,
ProjectWorkspaceRuntimeConfig,
WorkspaceRuntimeService,
} from "./workspace-runtime.js";
import type { AgentEnvConfig } from "./secrets.js";
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
export type ProjectWorkspaceVisibility = "default" | "advanced";

View File

@@ -1,14 +1,18 @@
import { z } from "zod";
import {
AGENT_ADAPTER_TYPES,
HUMAN_COMPANY_MEMBERSHIP_ROLES,
INVITE_JOIN_TYPES,
JOIN_REQUEST_STATUSES,
JOIN_REQUEST_TYPES,
MEMBERSHIP_STATUSES,
PERMISSION_KEYS,
} from "../constants.js";
import { optionalAgentAdapterTypeSchema } from "../adapter-type.js";
export const createCompanyInviteSchema = z.object({
allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"),
humanRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(),
defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(),
agentMessage: z.string().max(4000).optional().nullable(),
});
@@ -46,6 +50,14 @@ export const listJoinRequestsQuerySchema = z.object({
export type ListJoinRequestsQuery = z.infer<typeof listJoinRequestsQuerySchema>;
export const listCompanyInvitesQuerySchema = z.object({
state: z.enum(["active", "revoked", "accepted", "expired"]).optional(),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
offset: z.coerce.number().int().min(0).optional().default(0),
});
export type ListCompanyInvitesQuery = z.infer<typeof listCompanyInvitesQuerySchema>;
export const claimJoinRequestApiKeySchema = z.object({
claimSecret: z.string().min(16).max(256),
});
@@ -85,8 +97,82 @@ export const updateMemberPermissionsSchema = z.object({
export type UpdateMemberPermissions = z.infer<typeof updateMemberPermissionsSchema>;
export const updateCompanyMemberSchema = z.object({
membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(),
status: z.enum(MEMBERSHIP_STATUSES).optional(),
}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, {
message: "membershipRole or status is required",
});
export type UpdateCompanyMember = z.infer<typeof updateCompanyMemberSchema>;
export const updateCompanyMemberWithPermissionsSchema = z.object({
membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(),
status: z.enum(MEMBERSHIP_STATUSES).optional(),
grants: updateMemberPermissionsSchema.shape.grants.default([]),
}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, {
message: "membershipRole or status is required",
});
export type UpdateCompanyMemberWithPermissions = z.infer<typeof updateCompanyMemberWithPermissionsSchema>;
export const updateUserCompanyAccessSchema = z.object({
companyIds: z.array(z.string().uuid()).default([]),
});
export type UpdateUserCompanyAccess = z.infer<typeof updateUserCompanyAccessSchema>;
export const searchAdminUsersQuerySchema = z.object({
query: z.string().trim().max(120).optional().default(""),
});
export type SearchAdminUsersQuery = z.infer<typeof searchAdminUsersQuerySchema>;
const profileImageAssetPathPattern = /^\/api\/assets\/[^/?#]+\/content(?:\?[^#]*)?(?:#.*)?$/;
function isValidProfileImage(value: string): boolean {
if (profileImageAssetPathPattern.test(value)) return true;
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:";
} catch {
return false;
}
}
const profileImageSchema = z
.string()
.trim()
.min(1)
.max(4000)
.refine(isValidProfileImage, { message: "Invalid profile image URL" });
export const currentUserProfileSchema = z.object({
id: z.string().min(1),
email: z.string().email().nullable(),
name: z.string().min(1).max(120).nullable(),
image: profileImageSchema.nullable(),
});
export type CurrentUserProfile = z.infer<typeof currentUserProfileSchema>;
export const authSessionSchema = z.object({
session: z.object({
id: z.string().min(1),
userId: z.string().min(1),
}),
user: currentUserProfileSchema,
});
export type AuthSession = z.infer<typeof authSessionSchema>;
export const updateCurrentUserProfileSchema = z.object({
name: z.string().trim().min(1).max(120),
image: z
.union([profileImageSchema, z.literal(""), z.null()])
.optional()
.transform((value) => value === "" ? null : value),
});
export type UpdateCurrentUserProfile = z.infer<typeof updateCurrentUserProfileSchema>;

View File

@@ -99,7 +99,6 @@ export const workspaceRuntimeServiceSchema = z.object({
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
}).strict();
export const executionWorkspaceCloseReadinessSchema = z.object({
workspaceId: z.string().uuid(),
state: executionWorkspaceCloseReadinessStateSchema,

View File

@@ -253,22 +253,36 @@ export {
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
acceptInviteSchema,
listCompanyInvitesQuerySchema,
listJoinRequestsQuerySchema,
claimJoinRequestApiKeySchema,
boardCliAuthAccessLevelSchema,
createCliAuthChallengeSchema,
resolveCliAuthChallengeSchema,
currentUserProfileSchema,
authSessionSchema,
updateCurrentUserProfileSchema,
updateCompanyMemberSchema,
updateCompanyMemberWithPermissionsSchema,
updateMemberPermissionsSchema,
searchAdminUsersQuerySchema,
updateUserCompanyAccessSchema,
type CreateCompanyInvite,
type CreateOpenClawInvitePrompt,
type AcceptInvite,
type ListCompanyInvitesQuery,
type ListJoinRequestsQuery,
type ClaimJoinRequestApiKey,
type BoardCliAuthAccessLevel,
type CreateCliAuthChallenge,
type ResolveCliAuthChallenge,
type CurrentUserProfile,
type AuthSession,
type UpdateCurrentUserProfile,
type UpdateCompanyMember,
type UpdateCompanyMemberWithPermissions,
type UpdateMemberPermissions,
type SearchAdminUsersQuery,
type UpdateUserCompanyAccess,
} from "./access.js";

107
releases/v2026.414.0.md Normal file
View File

@@ -0,0 +1,107 @@
# v2026.414.0
> Released: 2026-04-14
## Security
- **Authorization hardening (GHSA-68qg-g8mg-6pr7)** — Scoped import, approval, activity, and heartbeat API routes to enforce proper authorization checks. Previously, certain administrative endpoints were accessible without adequate permission verification. All users are strongly encouraged to upgrade. ([#3315](https://github.com/paperclipai/paperclip/pull/3315), [#3009](https://github.com/paperclipai/paperclip/pull/3009), @KhairulA)
- **Removed hardcoded JWT secret fallback** — The `createBetterAuthInstance` function no longer falls back to a hardcoded JWT secret, closing a credential-hygiene gap. ([#3124](https://github.com/paperclipai/paperclip/pull/3124), @cleanunicorn)
- **Redact Bearer tokens in logs** — Server log output now redacts Bearer tokens to prevent accidental credential exposure.
- **Dependency bumps** — Updated `multer` to 2.1.1 (HIGH CVEs) and `rollup` to 4.59.0 (path-traversal CVE). ([#2909](https://github.com/paperclipai/paperclip/pull/2909), @marysomething99-prog)
## Highlights
- **Multi-user access and invites** — Full multi-user authentication, company roles, and invite management. Board users can create invite links, approve join requests, and manage member roles. Invite flows support auto-accept for signed-in users, paginated history, and human-readable requester identities in approval views.
- **Human user identities everywhere** — Human users now appear with real names and avatars across activity feeds, issue tables, assignee pickers, and @-mention menus. A lightweight user directory endpoint powers consistent identity resolution across the UI.
- **Issue chat thread** — Replaced the classic comment timeline with a full chat-style thread powered by assistant-ui. Agent run transcripts, chain-of-thought, and user messages render inline as a continuous conversation with polished avatars, action bars, and relative timestamps. ([#3079](https://github.com/paperclipai/paperclip/pull/3079))
- **External adapter plugin system** — Third-party adapters can now be installed as npm packages or loaded from local directories. Plugins declare a config schema and an optional UI transcript parser; built-in adapters can be overridden by external ones. Includes Hermes local session management and provider/model display in run details. ([#2649](https://github.com/paperclipai/paperclip/pull/2649), [#2650](https://github.com/paperclipai/paperclip/pull/2650), [#2651](https://github.com/paperclipai/paperclip/pull/2651), [#2654](https://github.com/paperclipai/paperclip/pull/2654), [#2655](https://github.com/paperclipai/paperclip/pull/2655), [#2659](https://github.com/paperclipai/paperclip/pull/2659), @plind-dm)
- **Execution policies** — Issues can carry a review/approval execution policy with multi-stage signoff workflows. Reviewers and approvers are selected per-stage, and Paperclip routes the issue through each stage automatically. ([#3222](https://github.com/paperclipai/paperclip/pull/3222))
- **Blocker dependencies** — First-class issue blocker relations with automatic wake-on-dependency-resolved. Set `blockedByIssueIds` on any issue and Paperclip wakes the assignee when all blockers reach `done`. ([#2797](https://github.com/paperclipai/paperclip/pull/2797))
- **Standalone MCP server** — New `@paperclipai/mcp-server` package exposing the Paperclip API as an MCP tool server, including approval creation. ([#2435](https://github.com/paperclipai/paperclip/pull/2435))
## Improvements
- **Invite UX polish** — Auto-submit for signed-in invites, inline auth flow, paginated invite history, requester identity in join approvals, and prevention of duplicate join requests and member re-invites.
- **Board approvals** — Generic issue-linked board approvals with card styling and visibility improvements in the issue detail sidebar. ([#3220](https://github.com/paperclipai/paperclip/pull/3220))
- **Inbox parent-child nesting** — Parent issues group their children in the inbox Mine view with a toggle button, j/k keyboard traversal across nested items, and collapsible groups. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz)
- **Inbox workspace grouping** — Issues can now be grouped by workspace in the inbox with collapsible mobile groups and shared column controls across inbox and issues lists. ([#3356](https://github.com/paperclipai/paperclip/pull/3356))
- **Issue search** — Trigram-indexed full-text search across titles, identifiers, descriptions, and comments with debounced input. Comment matches now surface in search results. ([#2999](https://github.com/paperclipai/paperclip/pull/2999))
- **Sub-issues inline** — Sub-issues moved from a separate tab to inline display on the issue detail, with parent-inherited workspace defaults and assignee propagation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355))
- **Issue-to-issue navigation** — Faster navigation between issues with scroll reset, prefetch, and detail-view optimizations. ([#3542](https://github.com/paperclipai/paperclip/pull/3542))
- **Auto-checkout for scoped wakes** — Agent harness now automatically checks out the scoped issue on comment-driven wakes, reducing latency for agent heartbeats. ([#3538](https://github.com/paperclipai/paperclip/pull/3538))
- **Document revision diff viewer** — Side-by-side diff viewer for issue document revisions with improved modal layout. ([#2792](https://github.com/paperclipai/paperclip/pull/2792))
- **Keyboard shortcuts cheatsheet** — Press `?` to open a keyboard shortcut reference dialog; new `g i` (go to inbox), `g c` (comment composer), and inbox archive undo shortcuts. ([#2772](https://github.com/paperclipai/paperclip/pull/2772))
- **Bedrock model selection** — Claude local adapter now supports AWS Bedrock authentication and model selection. ([#3033](https://github.com/paperclipai/paperclip/pull/3033), [#2793](https://github.com/paperclipai/paperclip/pull/2793), @kimnamu)
- **Codex fast mode** — Added fast mode support for the Codex local adapter with env probe safeguards. ([#3383](https://github.com/paperclipai/paperclip/pull/3383))
- **Backup improvements** — Gzip-compressed backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. ([#3015](https://github.com/paperclipai/paperclip/pull/3015), @aronprins)
- **GitHub webhook signing modes** — Added `github_hmac` and `none` webhook signing modes with timing-safe HMAC comparison. ([#1961](https://github.com/paperclipai/paperclip/pull/1961), @antonio-mello-ai)
- **Sidebar order persistence** — Sidebar project and company ordering preferences now persist per-user.
- **Workspace runtime controls** — Start/stop controls, runtime state reconciliation, runtime service improvements, and workspace branch/folder display in the issue properties sidebar. ([#3354](https://github.com/paperclipai/paperclip/pull/3354))
- **Attachment improvements** — Arbitrary file attachments (not just images), drag-and-drop non-image files onto markdown editor, and square-cropped image gallery grid. ([#2749](https://github.com/paperclipai/paperclip/pull/2749))
- **Image gallery in chat** — Clicking images in chat messages now opens a full gallery viewer.
- **Mobile UX** — Gmail-inspired mobile top bar for inbox issue views, responsive execution workspace pages, mobile mention menu placement, and mobile comment copy button feedback.
- **Routine improvements** — Draft routine defaults, run-time overrides, routine title variables, and relaxed project/agent requirements for routines. ([#3220](https://github.com/paperclipai/paperclip/pull/3220))
- **Project environment variables** — Projects can now define environment variables that are inherited by workspace runs.
- **Skill auto-enable** — Mentioned skills are automatically enabled for heartbeat runs.
- **Comment wake batching** — Multiple comment wakes are batched into a single inline payload for more efficient agent heartbeats.
- **Server-side adapter pause/resume** — Builtin adapter types can now be paused/resumed from the server with `overridePaused`. ([#2542](https://github.com/paperclipai/paperclip/pull/2542), @plind-dm)
- **Skill slash-command autocomplete** — Skill names now autocomplete in the editor.
- **Worktree reseed command** — New CLI command to reseed worktrees from latest repo state. ([#3353](https://github.com/paperclipai/paperclip/pull/3353))
## Fixes
- **Assignee name overflow** — Fixed long assignee names overflowing in the issues list grid with proper truncation.
- **Company alerts isolation** — Company-level alerts no longer appear in personal inbox.
- **Invite state management** — Fixed reused invite refresh pending state, paginated invite history cache isolation, and invite flow state mapping across reloads.
- **Issue detail stability** — Fixed visible refreshes during agent updates, comment post resets, ref update loops, split regressions, and main-pane focus on navigation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355))
- **Inbox badge count** — Badge now correctly counts only unread Mine issues. ([#2512](https://github.com/paperclipai/paperclip/pull/2512), @AllenHyang)
- **Inbox keyboard navigation** — Fixed j/k traversal across groups and nesting column alignment. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz)
- **Execution workspaces** — Fixed linked worktree reuse, dev runner isolation, workspace import regressions, and workspace preflight through server toolchain.
- **Stale execution locks** — Fixed stale execution lock lifecycle with proper `executionAgentNameKey` clearing. ([#2643](https://github.com/paperclipai/paperclip/pull/2643), @chrisschwer)
- **Agent env bindings** — Fixed cleared agent env bindings not persisting on save. ([#3232](https://github.com/paperclipai/paperclip/pull/3232), @officialasishkumar)
- **Capabilities field** — Fixed blank screen when clearing the Capabilities field. ([#2442](https://github.com/paperclipai/paperclip/pull/2442), @sparkeros)
- **Skill deletion** — Company skills can now be deleted with an agent usage check. ([#2441](https://github.com/paperclipai/paperclip/pull/2441), @DanielSousa)
- **Claude session resume** — Fixed `--append-system-prompt-file` being sent on resumed Claude sessions and preserved instructions on resume fallback. ([#2949](https://github.com/paperclipai/paperclip/pull/2949), [#2936](https://github.com/paperclipai/paperclip/pull/2936), [#2937](https://github.com/paperclipai/paperclip/pull/2937), @Lempkey)
- **Agent auth JWT** — Fixed agent auth to fall back to `BETTER_AUTH_SECRET` when `PAPERCLIP_AGENT_JWT_SECRET` is absent. ([#2866](https://github.com/paperclipai/paperclip/pull/2866), @ergonaworks)
- **Typing lag** — Fixed typing lag in long comment threads. ([#3163](https://github.com/paperclipai/paperclip/pull/3163))
- **Shimmer animation** — Fixed shimmer text using invalid `hsl()` wrapper on `oklch` colors, loop jitter, and added pause between repeats.
- **Mention selection** — Restored touch mention selection and fixed spaced mention queries.
- **Inbox archive** — Fixed archive flashing back after fade-out.
- **Goal description** — Made goal description area scrollable in create dialog. ([#2148](https://github.com/paperclipai/paperclip/pull/2148), @shoaib050326)
- **Worktree provisioning** — Fixed symlink relinking, fallback seeding, dependency hydration, and validated linked worktrees before reuse. ([#3354](https://github.com/paperclipai/paperclip/pull/3354))
- **Node keepAliveTimeout** — Increased timeout behind reverse proxies to prevent 502 errors.
- **Codex tool-use transcripts** — Fixed Codex tool-use transcript completion parsing.
- **Codex resume error** — Recognize missing-rollout Codex resume error as stale session.
- **Pi quota exhaustion** — Treat Pi quota exhaustion as a failed run. ([#2305](https://github.com/paperclipai/paperclip/pull/2305))
- **Issue identifier collisions** — Prevented identifier collisions during concurrent issue creation.
- **OpenClaw CEO paths** — Fixed `$AGENT_HOME` references in CEO onboarding instructions to use relative paths. ([#3299](https://github.com/paperclipai/paperclip/pull/3299), @aronprins)
- **Windows adapter** — Uses `cmd.exe` for `.cmd`/`.bat` wrappers on Windows. ([#2662](https://github.com/paperclipai/paperclip/pull/2662), @wbelt)
- **Markdown autoformat** — Fixed autoformat of pasted markdown in inline editor. ([#2733](https://github.com/paperclipai/paperclip/pull/2733), @davison)
- **Paused agent dimming** — Correctly dim paused agents in list and org chart views; skip dimming on Paused filter tab. ([#2397](https://github.com/paperclipai/paperclip/pull/2397), @HearthCore)
- **Import role fallback** — Import now reads agent role from frontmatter before defaulting to "agent". ([#2594](https://github.com/paperclipai/paperclip/pull/2594), @plind-dm)
- **Backup cleanup** — Clean up orphaned `.sql` files on compression failure and fix stale startup log.
## Upgrade Guide
Nine new database migrations (`0049``0056`) will run automatically on startup. These add:
- Issue blocker relations table (`0049`)
- Project environment variables (`0050`)
- Trigram search indexes on issues and comments (`0051` — requires `pg_trgm` extension)
- Execution policy decision tracking (`0052`)
- Non-issue inbox dismissals (`0053`)
- Relaxed routine constraints (`0054`)
- Heartbeat run process group tracking (`0055`)
- User sidebar preferences (`0056`)
All migrations are additive — no existing data is modified or removed.
**`pg_trgm` extension**: Migration `0051` creates the `pg_trgm` PostgreSQL extension for full-text search. If your database user does not have `CREATE EXTENSION` privileges, ask your DBA to run `CREATE EXTENSION IF NOT EXISTS pg_trgm;` before upgrading.
If you use external adapter plugins, note that built-in adapters can now be overridden by external ones. The `overriddenBuiltin` flag in the adapter API indicates when this is happening.
## Contributors
Thank you to everyone who contributed to this release!
@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @cryppadotta, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt

View File

@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts";
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
import { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts";
@@ -557,7 +557,7 @@ async function getDevHealthPayload() {
if (!response.ok) {
throw new Error(`Health request failed (${response.status})`);
}
return await parseJsonResponseWithLimit<{ devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } }>(response);
return await parseJsonResponseWithLimit(response);
}
async function waitForChildExit() {

View File

@@ -436,9 +436,6 @@ async function searchWindow(
for (let page = 2; page <= pageCount; page += 1) {
const response = await searchPage(client, options, start, end, page, 100);
if (response.incomplete_results) {
throw new Error(`GitHub returned incomplete search results for window ${windowKey} on page ${page}`);
}
ingestSearchItems(cache, response.items, shas);
}
@@ -512,7 +509,7 @@ function sortFilteredShas(cache: CacheFile, shas: string[]): string[] {
}
function formatQueryDate(value: Date): string {
return new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z");
return value.toISOString().replace(".000Z", "Z");
}
function ingestSearchItems(cache: CacheFile, items: SearchCommitItem[], shas: Set<string>) {
@@ -850,14 +847,6 @@ class GitHubClient {
return (await response.json()) as T;
}
const retryAfter = response.headers.get("retry-after");
if ((response.status === 403 || response.status === 429) && retryAfter) {
const waitMs = Math.max(Number.parseInt(retryAfter, 10) * 1000, 1_000);
console.error(`GitHub secondary rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`);
await sleep(waitMs);
continue;
}
const remaining = response.headers.get("x-ratelimit-remaining");
const resetAt = response.headers.get("x-ratelimit-reset");
if ((response.status === 403 || response.status === 429) && remaining === "0" && resetAt) {

View File

@@ -31,26 +31,35 @@ source_env_path="$(dirname "$source_config_path")/.env"
mkdir -p "$paperclip_dir"
run_paperclipai_command() {
local command_args=("$@")
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
pnpm paperclipai "${command_args[@]}"
run_isolated_worktree_init() {
local base_cli_runner="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
local base_cli_entry="$base_cwd/cli/src/index.ts"
if [[ -f "$base_cli_runner" && -f "$base_cli_entry" ]]; then
(
cd "$worktree_cwd"
node "$base_cli_runner" "$base_cli_entry" worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
)
return 0
fi
local base_cli_tsx_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs"
local base_cli_entry_path="$base_cwd/cli/src/index.ts"
if command -v node >/dev/null 2>&1 && [[ -f "$base_cli_tsx_path" ]] && [[ -f "$base_cli_entry_path" ]]; then
node "$base_cli_tsx_path" "$base_cli_entry_path" "${command_args[@]}"
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
(
cd "$worktree_cwd"
pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
)
return 0
fi
if command -v paperclipai >/dev/null 2>&1; then
paperclipai "${command_args[@]}"
(
cd "$worktree_cwd"
paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
)
return 0
fi
return 1
return 127
}
paperclipai_command_available() {
@@ -71,19 +80,6 @@ paperclipai_command_available() {
return 1
}
run_isolated_worktree_init() {
run_paperclipai_command \
worktree \
init \
--force \
--seed-mode \
minimal \
--name \
"$worktree_name" \
--from-config \
"$source_config_path"
}
write_fallback_worktree_config() {
WORKTREE_NAME="$worktree_name" \
BASE_CWD="$base_cwd" \

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,326 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { test, expect, type Browser, type Page } from "@playwright/test";
const BASE = process.env.PAPERCLIP_E2E_BASE_URL ?? "http://127.0.0.1:3105";
const DATA_DIR = process.env.PAPERCLIP_E2E_DATA_DIR ?? process.env.PAPERCLIP_HOME;
const CONFIG_PATH = process.env.PAPERCLIP_E2E_CONFIG_PATH ?? path.resolve(process.cwd(), ".paperclip/config.json");
const BOOTSTRAP_SCRIPT_PATH = path.resolve(process.cwd(), "packages/db/scripts/create-auth-bootstrap-invite.ts");
const OWNER_PASSWORD = "paperclip-owner-password";
const INVITED_PASSWORD = "paperclip-invited-password";
type HumanUser = {
name: string;
email: string;
password: string;
};
type CompanySummary = {
id: string;
name: string;
issuePrefix?: string | null;
};
type CompanyMember = {
id: string;
membershipRole: "owner" | "admin" | "operator" | "viewer";
status: "pending" | "active" | "suspended";
user: { id: string; email: string | null; name: string | null } | null;
};
type SessionJsonResponse<T> = {
ok: boolean;
status: number;
text: string;
json: T | null;
};
const runId = Date.now();
const companyName = `MU-Auth-${runId}`;
const ownerUser: HumanUser = {
name: "Owner User",
email: `owner-${runId}@paperclip.local`,
password: OWNER_PASSWORD,
};
const invitedUser: HumanUser = {
name: "Invited User",
email: `invitee-${runId}@paperclip.local`,
password: INVITED_PASSWORD,
};
function createBootstrapInvite() {
if (!DATA_DIR) {
throw new Error("PAPERCLIP_E2E_DATA_DIR or PAPERCLIP_HOME is required for authenticated bootstrap tests");
}
if (!existsSync(CONFIG_PATH)) {
throw new Error(`Authenticated bootstrap config not found at ${CONFIG_PATH}`);
}
if (!existsSync(BOOTSTRAP_SCRIPT_PATH)) {
throw new Error(`Authenticated bootstrap helper not found at ${BOOTSTRAP_SCRIPT_PATH}`);
}
const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
return execFileSync(
pnpmCommand,
[
"--filter",
"@paperclipai/db",
"exec",
"tsx",
BOOTSTRAP_SCRIPT_PATH,
"--config",
CONFIG_PATH,
"--base-url",
BASE,
],
{
cwd: process.cwd(),
env: {
...process.env,
FORCE_COLOR: "0",
NO_COLOR: "1",
PAPERCLIP_HOME: DATA_DIR,
},
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}
).trim();
}
async function signUp(page: Page, user: HumanUser) {
await page.goto(`${BASE}/auth`);
await expect(page.getByRole("heading", { name: "Sign in to Paperclip" })).toBeVisible();
await page.getByRole("button", { name: "Create one" }).click();
await expect(page.getByRole("heading", { name: "Create your Paperclip account" })).toBeVisible();
await page.getByLabel("Name").fill(user.name);
await page.getByLabel("Email").fill(user.email);
await page.getByLabel("Password").fill(user.password);
await page.getByRole("button", { name: "Create Account" }).click();
await expect(page).not.toHaveURL(/\/auth/, { timeout: 20_000 });
}
async function acceptBootstrapInvite(page: Page, inviteUrl: string) {
await page.goto(inviteUrl);
await expect(page.getByRole("heading", { name: "Bootstrap your Paperclip instance" })).toBeVisible();
await page.getByRole("button", { name: "Accept bootstrap invite" }).click();
await expect(page.getByRole("heading", { name: "Bootstrap complete" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("link", { name: "Open board" }).click();
}
async function createCompanyForSession(page: Page, nextCompanyName: string) {
const createRes = await sessionJsonRequest<CompanySummary>(page, `${BASE}/api/companies`, {
method: "POST",
data: { name: nextCompanyName },
});
expect(createRes.ok).toBe(true);
expect(createRes.json).toBeTruthy();
return createRes.json!;
}
async function createAuthenticatedInvite(page: Page, companyPrefix: string) {
await page.goto(`${BASE}/${companyPrefix}/company/settings`);
await expect(page.getByTestId("company-settings-invites-section")).toBeVisible({
timeout: 20_000,
});
await page.getByTestId("company-settings-human-invite-role").selectOption("operator");
await page.getByTestId("company-settings-create-human-invite").click();
const inviteField = page.getByTestId("company-settings-human-invite-url");
await expect(inviteField).toBeVisible({ timeout: 20_000 });
return (await inviteField.inputValue()).trim();
}
async function signUpFromInvite(page: Page, inviteUrl: string, user: HumanUser) {
await page.goto(inviteUrl);
await expect(page.getByText("Sign in or create an account before submitting a human join request.")).toBeVisible();
await page.getByRole("link", { name: "Sign in / Create account" }).click();
await expect(page).toHaveURL(/\/auth\?next=/);
await expect(page.getByRole("heading", { name: "Sign in to Paperclip" })).toBeVisible();
await page.getByRole("button", { name: "Create one" }).click();
await page.getByLabel("Name").fill(user.name);
await page.getByLabel("Email").fill(user.email);
await page.getByLabel("Password").fill(user.password);
await page.getByRole("button", { name: "Create Account" }).click();
await expect(page).toHaveURL(new RegExp(`/invite/[^/]+$`), { timeout: 20_000 });
}
async function acceptHumanInvite(page: Page) {
await expect(page.getByRole("button", { name: "Join company" })).toBeEnabled();
await page.getByRole("button", { name: "Join company" }).click();
await expect(page.getByRole("heading", { name: "You joined the company" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("link", { name: "Open board" }).click();
}
async function sessionJsonRequest<T>(
page: Page,
url: string,
options: {
method?: string;
data?: unknown;
} = {}
) {
return page.evaluate(
async ({ url: targetUrl, method, data }) => {
const response = await fetch(targetUrl, {
method,
credentials: "include",
headers: data === undefined ? undefined : { "Content-Type": "application/json" },
body: data === undefined ? undefined : JSON.stringify(data),
});
const text = await response.text();
let json: unknown = null;
if (text.length > 0) {
try {
json = JSON.parse(text);
} catch {
json = null;
}
}
return {
ok: response.ok,
status: response.status,
text,
json,
};
},
{
url,
method: options.method ?? "GET",
data: options.data,
}
) as Promise<SessionJsonResponse<T>>;
}
async function waitForMember(page: Page, companyId: string, email: string) {
let member: CompanyMember | null = null;
await expect
.poll(
async () => {
const membersRes = await sessionJsonRequest<{ members: CompanyMember[] }>(
page,
`${BASE}/api/companies/${companyId}/members`
);
expect(membersRes.ok).toBe(true);
const body = membersRes.json;
if (!body) return null;
member = body.members.find((entry) => entry.user?.email === email) ?? null;
return member;
},
{
timeout: 20_000,
intervals: [500, 1_000, 2_000],
}
)
.toMatchObject({
status: "active",
membershipRole: "operator",
user: { email },
});
return member!;
}
async function waitForMemberRole(
page: Page,
companyId: string,
memberId: string,
membershipRole: CompanyMember["membershipRole"]
) {
await expect
.poll(
async () => {
const membersRes = await sessionJsonRequest<{ members: CompanyMember[] }>(
page,
`${BASE}/api/companies/${companyId}/members`
);
expect(membersRes.ok).toBe(true);
const body = membersRes.json;
if (!body) return null;
return body.members.find((member) => member.id === memberId) ?? null;
},
{
timeout: 20_000,
intervals: [500, 1_000, 2_000],
}
)
.toMatchObject({
id: memberId,
membershipRole,
});
}
async function newPage(browser: Browser) {
const context = await browser.newContext();
const page = await context.newPage();
return { context, page };
}
test.describe("Multi-user: authenticated mode", () => {
test("authenticated humans can bootstrap, invite, join, and respect viewer restrictions", async ({
browser,
page,
}) => {
test.setTimeout(180_000);
const healthRes = await page.request.get(`${BASE}/api/health`);
expect(healthRes.ok()).toBe(true);
const health = (await healthRes.json()) as {
deploymentMode?: string;
bootstrapStatus?: string;
};
expect(health.deploymentMode).toBe("authenticated");
await signUp(page, ownerUser);
await acceptBootstrapInvite(page, createBootstrapInvite());
const company = await createCompanyForSession(page, companyName);
const companyPrefix = company.issuePrefix ?? company.id;
await page.goto(`${BASE}/${companyPrefix}/dashboard`);
await expect(page.getByTestId("layout-account-menu-trigger")).toContainText(ownerUser.name);
await page.getByTestId("layout-account-menu-trigger").click();
await expect(page.getByText(ownerUser.email)).toBeVisible();
const inviteUrl = await createAuthenticatedInvite(page, companyPrefix);
const invited = await newPage(browser);
try {
await signUpFromInvite(invited.page, inviteUrl, invitedUser);
await acceptHumanInvite(invited.page);
await expect(invited.page).not.toHaveURL(/\/auth/, { timeout: 10_000 });
const joinedMember = await waitForMember(page, company.id, invitedUser.email);
await page.goto(`${BASE}/${companyPrefix}/company/settings`);
const roleSelect = page.getByTestId(`company-settings-member-role-${joinedMember.id}`);
await expect(roleSelect).toBeVisible({ timeout: 20_000 });
await roleSelect.selectOption("viewer");
await expect(roleSelect).toHaveValue("viewer");
await waitForMemberRole(page, company.id, joinedMember.id, "viewer");
await invited.page.goto(`${BASE}/${companyPrefix}/company/settings`);
await expect(
invited.page.getByText("Your current company role cannot create human invites.")
).toBeVisible({ timeout: 20_000 });
await expect(
invited.page.getByTestId("company-settings-create-human-invite")
).toHaveCount(0);
const forbiddenInviteRes = await sessionJsonRequest(
invited.page,
`${BASE}/api/companies/${company.id}/invites`,
{
method: "POST",
data: {
allowedJoinTypes: "human",
humanRole: "viewer",
},
}
);
expect(forbiddenInviteRes.status).toBe(403);
} finally {
await invited.context.close();
}
});
});

View File

@@ -0,0 +1,518 @@
import { test, expect, type Page, type APIRequestContext } from "@playwright/test";
/**
* E2E: Multi-user implementation tests (local_trusted mode).
*
* Covers:
* 1. Company member management API (list, update role, suspend)
* 2. Human invite creation and acceptance API
* 3. Company Settings UI — member list, role editing, invite creation
* 4. Invite landing page UI
* 5. Role-based access control (viewer read-only)
* 6. Last-owner protection
*/
const BASE = process.env.PAPERCLIP_E2E_BASE_URL ?? "http://127.0.0.1:3104";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Ensure the server is bootstrapped (claimed) before running tests. */
async function ensureBootstrapped(request: APIRequestContext): Promise<void> {
const healthRes = await request.get(`${BASE}/api/health`);
const health = await healthRes.json();
if (health.bootstrapStatus === "ready") return;
// If bootstrap_pending, we need to use the claim token from the bootstrap invite.
// In local_trusted mode, just try hitting companies — that should auto-bootstrap.
if (health.deploymentMode === "local_trusted") {
// local_trusted should work without explicit bootstrap
return;
}
}
/** Create a company via the onboarding wizard API shortcut. */
async function createCompanyViaWizard(
request: APIRequestContext,
name: string
): Promise<{ companyId: string; agentId: string; prefix: string }> {
await ensureBootstrapped(request);
const createRes = await request.post(`${BASE}/api/companies`, {
data: { name },
});
if (!createRes.ok()) {
const errText = await createRes.text();
throw new Error(
`Failed to create company (${createRes.status()}): ${errText}`
);
}
const company = await createRes.json();
// Create a CEO agent
const agentRes = await request.post(
`${BASE}/api/companies/${company.id}/agents`,
{
data: {
name: "CEO",
role: "ceo",
title: "CEO",
adapterType: "claude_local",
},
}
);
expect(agentRes.ok()).toBe(true);
const agent = await agentRes.json();
return {
companyId: company.id,
agentId: agent.id,
prefix: company.issuePrefix ?? company.id,
};
}
/** Create a human invite and return token + invite URL. */
async function createHumanInvite(
request: APIRequestContext,
companyId: string,
role: string = "operator"
): Promise<{ token: string; inviteUrl: string; inviteId: string }> {
const res = await request.post(
`${BASE}/api/companies/${companyId}/invites`,
{
data: {
allowedJoinTypes: "human",
humanRole: role,
},
}
);
expect(res.ok()).toBe(true);
const body = await res.json();
return {
token: body.token,
inviteUrl: body.inviteUrl,
inviteId: body.id,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe("Multi-user: API", () => {
let companyId: string;
test.beforeAll(async ({ request }) => {
const result = await createCompanyViaWizard(
request,
`MU-API-${Date.now()}`
);
companyId = result.companyId;
});
test("GET /companies/:id/members returns member list with access info", async ({
request,
}) => {
const res = await request.get(
`${BASE}/api/companies/${companyId}/members`
);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body).toHaveProperty("members");
expect(body).toHaveProperty("access");
expect(Array.isArray(body.members)).toBe(true);
expect(body.access).toHaveProperty("currentUserRole");
expect(body.access).toHaveProperty("canManageMembers");
expect(body.access).toHaveProperty("canInviteUsers");
});
test("POST /companies/:id/invites creates a human invite with role", async ({
request,
}) => {
const res = await request.post(
`${BASE}/api/companies/${companyId}/invites`,
{
data: {
allowedJoinTypes: "human",
humanRole: "operator",
},
}
);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body).toHaveProperty("token");
expect(body).toHaveProperty("inviteUrl");
expect(body.allowedJoinTypes).toBe("human");
expect(body.inviteUrl).toContain("/invite/");
});
test("GET /invites/:token returns invite summary", async ({ request }) => {
const invite = await createHumanInvite(request, companyId, "viewer");
const res = await request.get(`${BASE}/api/invites/${invite.token}`);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body).toHaveProperty("companyId");
expect(body).toHaveProperty("allowedJoinTypes");
expect(body.allowedJoinTypes).toBe("human");
expect(body).toHaveProperty("inviteType");
expect(body.inviteType).toBe("company_join");
});
test("POST /invites/:token/accept (human) creates membership", async ({
request,
}) => {
const invite = await createHumanInvite(request, companyId, "operator");
const acceptRes = await request.post(
`${BASE}/api/invites/${invite.token}/accept`,
{
data: { requestType: "human" },
}
);
expect(acceptRes.ok()).toBe(true);
const body = await acceptRes.json();
// In local_trusted, human accept should succeed
expect(body).toHaveProperty("id");
});
test("POST /invites/:token/accept rejects agent on human-only invite", async ({
request,
}) => {
const invite = await createHumanInvite(request, companyId, "operator");
const acceptRes = await request.post(
`${BASE}/api/invites/${invite.token}/accept`,
{
data: { requestType: "agent", agentName: "Rogue" },
}
);
expect(acceptRes.ok()).toBe(false);
expect(acceptRes.status()).toBe(400);
});
test("POST /companies/:id/invites supports all four roles", async ({
request,
}) => {
for (const role of ["owner", "admin", "operator", "viewer"]) {
const res = await request.post(
`${BASE}/api/companies/${companyId}/invites`,
{
data: { allowedJoinTypes: "human", humanRole: role },
}
);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.token).toBeTruthy();
}
});
test("PATCH /companies/:id/members/:memberId cannot remove last owner", async ({
request,
}) => {
// Create a fresh company for this test
const fresh = await createCompanyViaWizard(
request,
`MU-LastOwner-${Date.now()}`
);
// First promote the local-board member to owner
const membersRes = await request.get(
`${BASE}/api/companies/${fresh.companyId}/members`
);
const { members } = await membersRes.json();
// Find the board member (should be the only one)
const boardMember = members.find(
(m: { principalId: string }) => m.principalId === "local-board"
);
if (!boardMember) {
test.skip();
return;
}
// Promote to owner first
const promoteRes = await request.patch(
`${BASE}/api/companies/${fresh.companyId}/members/${boardMember.id}`,
{ data: { membershipRole: "owner" } }
);
expect(promoteRes.ok()).toBe(true);
// Now try to demote the last (and only) owner to operator — should fail
const demoteRes = await request.patch(
`${BASE}/api/companies/${fresh.companyId}/members/${boardMember.id}`,
{ data: { membershipRole: "operator" } }
);
expect(demoteRes.status()).toBe(409);
const errBody = await demoteRes.json();
expect(JSON.stringify(errBody)).toContain("last active owner");
});
test("POST /companies/:id/openclaw/invite-prompt creates agent invite", async ({
request,
}) => {
const res = await request.post(
`${BASE}/api/companies/${companyId}/openclaw/invite-prompt`,
{
data: { agentMessage: "E2E test agent invite" },
}
);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body).toHaveProperty("token");
expect(body).toHaveProperty("inviteUrl");
expect(body.allowedJoinTypes).toBe("agent");
});
});
test.describe("Multi-user: Company Settings UI", () => {
let companyId: string;
let companyPrefix: string;
test.beforeAll(async ({ request }) => {
const result = await createCompanyViaWizard(
request,
`MU-UI-${Date.now()}`
);
companyId = result.companyId;
companyPrefix = result.prefix;
});
test("shows Team and Invites sections on settings page", async ({ page }) => {
await page.goto(`${BASE}/${companyPrefix}/company/settings`);
await page.waitForLoadState("networkidle");
await expect(page.getByTestId("company-settings-invites-section")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("company-settings-team-section")).toBeVisible({
timeout: 10_000,
});
});
test("shows human invite creation controls", async ({ page }) => {
await page.goto(`${BASE}/${companyPrefix}/company/settings`);
await page.waitForLoadState("networkidle");
const inviteButton = page.getByTestId("company-settings-create-human-invite");
await expect(inviteButton).toBeVisible({ timeout: 10_000 });
const roleSelect = page.getByTestId("company-settings-human-invite-role");
await expect(roleSelect).toBeVisible();
});
test("can create human invite and shows URL", async ({ page }) => {
await page.goto(`${BASE}/${companyPrefix}/company/settings`);
await page.waitForLoadState("networkidle");
const inviteButton = page.getByTestId("company-settings-create-human-invite");
await expect(inviteButton).toBeVisible({ timeout: 10_000 });
await inviteButton.click();
await expect(page.getByTestId("company-settings-human-invite-url")).toBeVisible({
timeout: 10_000,
});
});
});
test.describe("Multi-user: Invite Landing UI", () => {
let companyId: string;
let inviteToken: string;
test.beforeAll(async ({ request }) => {
const result = await createCompanyViaWizard(
request,
`MU-Invite-${Date.now()}`
);
companyId = result.companyId;
const invite = await createHumanInvite(request, companyId, "operator");
inviteToken = invite.token;
});
test("invite landing page loads with join options", async ({ page }) => {
await page.goto(`${BASE}/invite/${inviteToken}`);
await page.waitForLoadState("networkidle");
// Should show the invite landing page heading
await expect(
page.getByRole("heading", { name: /join/i })
).toBeVisible({ timeout: 10_000 });
});
test("invite landing shows human join type", async ({ page }) => {
await page.goto(`${BASE}/invite/${inviteToken}`);
await page.waitForLoadState("networkidle");
// For a human-only invite, should show human join option
const humanOption = page.locator("text=/human/i");
await expect(humanOption).toBeVisible({ timeout: 10_000 });
});
test("expired/invalid invite token returns error", async ({ page }) => {
await page.goto(`${BASE}/invite/invalid-token-e2e-test`);
await page.waitForLoadState("networkidle");
await expect(page.getByTestId("invite-error")).toBeVisible({ timeout: 10_000 });
});
});
test.describe("Multi-user: Member role management API", () => {
let companyId: string;
test.beforeAll(async ({ request }) => {
const result = await createCompanyViaWizard(
request,
`MU-Roles-${Date.now()}`
);
companyId = result.companyId;
});
test("invite + accept creates member with correct role", async ({
request,
}) => {
// Create invite for 'viewer' role
const invite = await createHumanInvite(request, companyId, "viewer");
// Accept the invite
const acceptRes = await request.post(
`${BASE}/api/invites/${invite.token}/accept`,
{ data: { requestType: "human" } }
);
expect(acceptRes.ok()).toBe(true);
// Check members list
const membersRes = await request.get(
`${BASE}/api/companies/${companyId}/members`
);
const { members } = await membersRes.json();
// Should have at least one member (the creator/local-board)
expect(members.length).toBeGreaterThanOrEqual(1);
});
test("PATCH member role updates correctly", async ({ request }) => {
// First create an invite and accept it to get a second member
const invite = await createHumanInvite(request, companyId, "operator");
const acceptRes = await request.post(
`${BASE}/api/invites/${invite.token}/accept`,
{ data: { requestType: "human" } }
);
expect(acceptRes.ok()).toBe(true);
// List members
const membersRes = await request.get(
`${BASE}/api/companies/${companyId}/members`
);
const { members } = await membersRes.json();
// Find a non-owner member to modify
const nonOwner = members.find(
(m: { membershipRole: string }) => m.membershipRole !== "owner"
);
if (!nonOwner) {
test.skip();
return;
}
// Update role to admin
const patchRes = await request.patch(
`${BASE}/api/companies/${companyId}/members/${nonOwner.id}`,
{ data: { membershipRole: "admin" } }
);
expect(patchRes.ok()).toBe(true);
const updated = await patchRes.json();
expect(updated.membershipRole).toBe("admin");
});
test("PATCH member status to suspended works", async ({ request }) => {
// Create another member
const invite = await createHumanInvite(request, companyId, "operator");
await request.post(`${BASE}/api/invites/${invite.token}/accept`, {
data: { requestType: "human" },
});
const membersRes = await request.get(
`${BASE}/api/companies/${companyId}/members`
);
const { members } = await membersRes.json();
const nonOwner = members.find(
(m: { membershipRole: string; status: string }) =>
m.membershipRole !== "owner" && m.status === "active"
);
if (!nonOwner) {
test.skip();
return;
}
const patchRes = await request.patch(
`${BASE}/api/companies/${companyId}/members/${nonOwner.id}`,
{ data: { status: "suspended" } }
);
expect(patchRes.ok()).toBe(true);
const updated = await patchRes.json();
expect(updated.status).toBe("suspended");
});
});
test.describe("Multi-user: Agent invite flow", () => {
let companyId: string;
test.beforeAll(async ({ request }) => {
const result = await createCompanyViaWizard(
request,
`MU-Agent-${Date.now()}`
);
companyId = result.companyId;
});
test("agent invite accept creates pending join request", async ({
request,
}) => {
// Create agent invite
const res = await request.post(
`${BASE}/api/companies/${companyId}/openclaw/invite-prompt`,
{ data: {} }
);
expect(res.ok()).toBe(true);
const { token } = await res.json();
// Accept as agent
const acceptRes = await request.post(
`${BASE}/api/invites/${token}/accept`,
{
data: {
requestType: "agent",
agentName: "TestAgent",
adapterType: "claude_local",
},
}
);
expect(acceptRes.ok()).toBe(true);
const body = await acceptRes.json();
expect(body).toHaveProperty("id");
expect(body.status).toBe("pending_approval");
});
test("join requests list shows pending agent request", async ({
request,
}) => {
const res = await request.get(
`${BASE}/api/companies/${companyId}/join-requests?status=pending_approval`
);
expect(res.ok()).toBe(true);
const requests = await res.json();
expect(Array.isArray(requests)).toBe(true);
});
});
test.describe("Multi-user: Health check integration", () => {
test("health endpoint reports deployment mode", async ({ request }) => {
const res = await request.get(`${BASE}/api/health`);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body).toHaveProperty("deploymentMode");
expect(body).toHaveProperty("authReady");
expect(body.authReady).toBe(true);
});
});

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "@playwright/test";
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3105);
const BASE_URL = process.env.PAPERCLIP_E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`;
export default defineConfig({
testDir: ".",
testMatch: "multi-user-authenticated.spec.ts",
timeout: 180_000,
expect: {
timeout: 20_000,
},
retries: 0,
use: {
baseURL: BASE_URL,
headless: true,
screenshot: "only-on-failure",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { browserName: "chromium" },
},
],
outputDir: "./test-results",
reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "@playwright/test";
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3104);
const BASE_URL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
testDir: ".",
testMatch: "multi-user.spec.ts",
timeout: 60_000,
retries: 0,
use: {
baseURL: BASE_URL,
headless: true,
screenshot: "only-on-failure",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { browserName: "chromium" },
},
],
// No webServer — expects an already-running server at BASE_URL.
outputDir: "./test-results",
reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
});

View File

@@ -12,6 +12,9 @@ const PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-e2e-home
export default defineConfig({
testDir: ".",
testMatch: "**/*.spec.ts",
// These suites target dedicated multi-user configurations/ports and are
// intentionally not part of the default local_trusted e2e run.
testIgnore: ["multi-user.spec.ts", "multi-user-authenticated.spec.ts"],
timeout: 60_000,
retries: 0,
use: {

146
ui/src/App.test.tsx Normal file
View File

@@ -0,0 +1,146 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CloudAccessGate } from "./components/CloudAccessGate";
const mockHealthApi = vi.hoisted(() => ({
get: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockAccessApi = vi.hoisted(() => ({
getCurrentBoardAccess: vi.fn(),
}));
vi.mock("./api/health", () => ({
healthApi: mockHealthApi,
}));
vi.mock("./api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("./api/access", () => ({
accessApi: mockAccessApi,
}));
vi.mock("@/lib/router", () => ({
Navigate: ({ to }: { to: string }) => <div>Navigate:{to}</div>,
Outlet: () => <div>Outlet content</div>,
Route: ({ children }: { children?: ReactNode }) => <>{children}</>,
Routes: ({ children }: { children?: ReactNode }) => <>{children}</>,
useLocation: () => ({ pathname: "/instance/settings/general", search: "", hash: "" }),
useParams: () => ({}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("CloudAccessGate", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockHealthApi.get.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
bootstrapStatus: "ready",
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("shows a no-access message for signed-in users without org access", async () => {
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
});
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
userId: "user-1",
isInstanceAdmin: false,
companyIds: [],
source: "session",
keyId: null,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudAccessGate />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await flushReact();
expect(container.textContent).toContain("No company access");
expect(container.textContent).not.toContain("Outlet content");
await act(async () => {
root.unmount();
});
});
it("allows authenticated users with company access through to the board", async () => {
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
});
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
userId: "user-1",
isInstanceAdmin: false,
companyIds: ["company-1"],
source: "session",
keyId: null,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudAccessGate />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await flushReact();
expect(container.textContent).toContain("Outlet content");
expect(container.textContent).not.toContain("No company access");
await act(async () => {
root.unmount();
});
});
});

View File

@@ -1,10 +1,8 @@
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Layout } from "./components/Layout";
import { OnboardingWizard } from "./components/OnboardingWizard";
import { authApi } from "./api/auth";
import { healthApi } from "./api/health";
import { CloudAccessGate } from "./components/CloudAccessGate";
import { Dashboard } from "./pages/Dashboard";
import { Companies } from "./pages/Companies";
import { Agents } from "./pages/Agents";
@@ -25,18 +23,23 @@ import { Costs } from "./pages/Costs";
import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanyAccess } from "./pages/CompanyAccess";
import { CompanyInvites } from "./pages/CompanyInvites";
import { CompanySkills } from "./pages/CompanySkills";
import { CompanyExport } from "./pages/CompanyExport";
import { CompanyImport } from "./pages/CompanyImport";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings";
import { InstanceAccess } from "./pages/InstanceAccess";
import { InstanceSettings } from "./pages/InstanceSettings";
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
import { ProfileSettings } from "./pages/ProfileSettings";
import { PluginManager } from "./pages/PluginManager";
import { PluginSettings } from "./pages/PluginSettings";
import { AdapterManager } from "./pages/AdapterManager";
import { PluginPage } from "./pages/PluginPage";
import { IssueChatUxLab } from "./pages/IssueChatUxLab";
import { InviteUxLab } from "./pages/InviteUxLab";
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
@@ -44,80 +47,13 @@ import { AuthPage } from "./pages/Auth";
import { BoardClaimPage } from "./pages/BoardClaim";
import { CliAuthPage } from "./pages/CliAuth";
import { InviteLandingPage } from "./pages/InviteLanding";
import { JoinRequestQueue } from "./pages/JoinRequestQueue";
import { NotFoundPage } from "./pages/NotFound";
import { queryKeys } from "./lib/queryKeys";
import { useCompany } from "./context/CompanyContext";
import { useDialog } from "./context/DialogContext";
import { loadLastInboxTab } from "./lib/inbox";
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">Instance setup required</h1>
<p className="mt-2 text-sm text-muted-foreground">
{hasActiveInvite
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
</p>
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
{`pnpm paperclipai auth bootstrap-ceo`}
</pre>
</div>
</div>
);
}
function CloudAccessGate() {
const location = useLocation();
const healthQuery = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
const data = query.state.data as
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
| undefined;
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
? 2000
: false;
},
refetchIntervalInBackground: true,
});
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
const sessionQuery = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
enabled: isAuthenticatedMode,
retry: false,
});
if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
if (healthQuery.error) {
return (
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
{healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"}
</div>
);
}
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
}
if (isAuthenticatedMode && !sessionQuery.data) {
const next = encodeURIComponent(`${location.pathname}${location.search}`);
return <Navigate to={`/auth?next=${next}`} replace />;
}
return <Outlet />;
}
function boardRoutes() {
return (
<>
@@ -126,6 +62,8 @@ function boardRoutes() {
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/settings/access" element={<CompanyAccess />} />
<Route path="company/settings/invites" element={<CompanyInvites />} />
<Route path="company/export/*" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} />
<Route path="skills/*" element={<CompanySkills />} />
@@ -177,9 +115,11 @@ function boardRoutes() {
<Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/requests" element={<JoinRequestQueue />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
<Route path="tests/ux/invites" element={<InviteUxLab />} />
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
<Route path="instance/settings/adapters" element={<AdapterManager />} />
<Route path=":pluginRoutePath" element={<PluginPage />} />
@@ -323,7 +263,9 @@ export function App() {
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
<Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="profile" element={<ProfileSettings />} />
<Route path="general" element={<InstanceGeneralSettings />} />
<Route path="access" element={<InstanceAccess />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} />

View File

@@ -1,12 +1,17 @@
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
import type { AgentAdapterType, JoinRequest, PermissionKey } from "@paperclipai/shared";
import { api } from "./client";
export type HumanCompanyRole = "owner" | "admin" | "operator" | "viewer";
type InviteSummary = {
id: string;
companyId: string | null;
companyName?: string | null;
companyLogoUrl?: string | null;
companyBrandColor?: string | null;
inviteType: "company_join" | "bootstrap_ceo";
allowedJoinTypes: "human" | "agent" | "both";
humanRole?: HumanCompanyRole | null;
expiresAt: string;
onboardingPath?: string;
onboardingUrl?: string;
@@ -15,6 +20,9 @@ type InviteSummary = {
skillIndexPath?: string;
skillIndexUrl?: string;
inviteMessage?: string | null;
invitedByUserName?: string | null;
joinRequestStatus?: JoinRequest["status"] | null;
joinRequestType?: JoinRequest["requestType"] | null;
};
type AcceptInviteInput =
@@ -88,17 +96,162 @@ type CompanyInviteCreated = {
inviteUrl: string;
expiresAt: string;
allowedJoinTypes: "human" | "agent" | "both";
humanRole?: HumanCompanyRole | null;
companyName?: string | null;
onboardingTextPath?: string;
onboardingTextUrl?: string;
inviteMessage?: string | null;
};
export type CompanyMemberGrant = {
id: string;
companyId: string;
principalType: "user";
principalId: string;
permissionKey: PermissionKey;
scope: Record<string, unknown> | null;
grantedByUserId: string | null;
createdAt: string;
updatedAt: string;
};
export type CompanyMember = {
id: string;
companyId: string;
principalType: "user";
principalId: string;
status: "pending" | "active" | "suspended";
membershipRole: HumanCompanyRole | null;
createdAt: string;
updatedAt: string;
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
grants: CompanyMemberGrant[];
};
export type CompanyMembersResponse = {
members: CompanyMember[];
access: {
currentUserRole: HumanCompanyRole | null;
canManageMembers: boolean;
canInviteUsers: boolean;
canApproveJoinRequests: boolean;
};
};
export type CompanyUserDirectoryEntry = {
principalId: string;
status: "active";
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
};
export type CompanyUserDirectoryResponse = {
users: CompanyUserDirectoryEntry[];
};
export type CompanyInviteRecord = {
id: string;
companyId: string | null;
companyName: string | null;
inviteType: "company_join" | "bootstrap_ceo";
allowedJoinTypes: "human" | "agent" | "both";
humanRole: HumanCompanyRole | null;
defaultsPayload: Record<string, unknown> | null;
expiresAt: string;
invitedByUserId: string | null;
revokedAt: string | null;
acceptedAt: string | null;
createdAt: string;
updatedAt: string;
inviteMessage: string | null;
state: "active" | "revoked" | "accepted" | "expired";
invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
relatedJoinRequestId: string | null;
};
export type CompanyInviteListResponse = {
invites: CompanyInviteRecord[];
nextOffset: number | null;
};
export type CompanyJoinRequest = JoinRequest & {
requesterUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
approvedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
rejectedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
invite: {
id: string;
inviteType: "company_join" | "bootstrap_ceo";
allowedJoinTypes: "human" | "agent" | "both";
humanRole: HumanCompanyRole | null;
inviteMessage: string | null;
createdAt: string;
expiresAt: string;
revokedAt: string | null;
acceptedAt: string | null;
invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null;
} | null;
};
export type AdminUserDirectoryEntry = {
id: string;
email: string | null;
name: string | null;
image: string | null;
isInstanceAdmin: boolean;
activeCompanyMembershipCount: number;
};
export type UserCompanyAccessEntry = {
id: string;
companyId: string;
principalType: "user";
principalId: string;
status: "pending" | "active" | "suspended";
membershipRole: HumanCompanyRole | "member" | null;
createdAt: string;
updatedAt: string;
companyName: string | null;
companyStatus: "active" | "paused" | "archived" | null;
};
export type UserCompanyAccessResponse = {
user: {
id: string;
email: string | null;
name: string | null;
image: string | null;
isInstanceAdmin: boolean;
} | null;
companyAccess: UserCompanyAccessEntry[];
};
export type CurrentBoardAccess = {
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
userId: string;
isInstanceAdmin: boolean;
companyIds: string[];
source: string;
keyId: string | null;
};
function buildInviteListQuery(options: {
state?: "active" | "revoked" | "accepted" | "expired";
limit?: number;
offset?: number;
}) {
const params = new URLSearchParams();
if (options.state) params.set("state", options.state);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
const query = params.toString();
return query ? `?${query}` : "";
}
export const accessApi = {
createCompanyInvite: (
companyId: string,
input: {
allowedJoinTypes?: "human" | "agent" | "both";
humanRole?: HumanCompanyRole | null;
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
} = {},
@@ -126,8 +279,67 @@ export const accessApi = {
input,
),
listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") =>
api.get<JoinRequest[]>(`/companies/${companyId}/join-requests?status=${status}`),
listInvites: (
companyId: string,
options: {
state?: "active" | "revoked" | "accepted" | "expired";
limit?: number;
offset?: number;
} = {},
) =>
api.get<CompanyInviteListResponse>(
`/companies/${companyId}/invites${buildInviteListQuery(options)}`,
),
revokeInvite: (inviteId: string) => api.post(`/invites/${inviteId}/revoke`, {}),
listJoinRequests: (
companyId: string,
status: "pending_approval" | "approved" | "rejected" = "pending_approval",
requestType?: "human" | "agent",
) =>
api.get<CompanyJoinRequest[]>(
`/companies/${companyId}/join-requests?status=${status}${requestType ? `&requestType=${requestType}` : ""}`,
),
listMembers: (companyId: string) =>
api.get<CompanyMembersResponse>(`/companies/${companyId}/members`),
listUserDirectory: (companyId: string) =>
api.get<CompanyUserDirectoryResponse>(`/companies/${companyId}/user-directory`),
updateMember: (
companyId: string,
memberId: string,
input: {
membershipRole?: HumanCompanyRole | null;
status?: "pending" | "active" | "suspended";
},
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}`, input),
updateMemberPermissions: (
companyId: string,
memberId: string,
input: {
grants: Array<{
permissionKey: PermissionKey;
scope?: Record<string, unknown> | null;
}>;
},
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/permissions`, input),
updateMemberAccess: (
companyId: string,
memberId: string,
input: {
membershipRole?: HumanCompanyRole | null;
status?: "pending" | "active" | "suspended";
grants: Array<{
permissionKey: PermissionKey;
scope?: Record<string, unknown> | null;
}>;
},
) => api.patch<CompanyMember>(`/companies/${companyId}/members/${memberId}/role-and-grants`, input),
approveJoinRequest: (companyId: string, requestId: string) =>
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
@@ -158,4 +370,22 @@ export const accessApi = {
cancelCliAuthChallenge: (id: string, token: string) =>
api.post<{ cancelled: boolean; status: string }>(`/cli-auth/challenges/${id}/cancel`, { token }),
searchAdminUsers: (query: string) =>
api.get<AdminUserDirectoryEntry[]>(`/admin/users?query=${encodeURIComponent(query)}`),
promoteInstanceAdmin: (userId: string) =>
api.post(`/admin/users/${userId}/promote-instance-admin`, {}),
demoteInstanceAdmin: (userId: string) =>
api.post(`/admin/users/${userId}/demote-instance-admin`, {}),
getUserCompanyAccess: (userId: string) =>
api.get<UserCompanyAccessResponse>(`/admin/users/${userId}/company-access`),
setUserCompanyAccess: (userId: string, companyIds: string[]) =>
api.put<UserCompanyAccessResponse>(`/admin/users/${userId}/company-access`, { companyIds }),
getCurrentBoardAccess: () =>
api.get<CurrentBoardAccess>("/cli-auth/me"),
};

View File

@@ -1,27 +1,63 @@
export type AuthSession = {
session: { id: string; userId: string };
user: { id: string; email: string | null; name: string | null };
};
import {
authSessionSchema,
currentUserProfileSchema,
type AuthSession,
type CurrentUserProfile,
type UpdateCurrentUserProfile,
} from "@paperclipai/shared";
type AuthErrorBody =
| {
code?: string;
message?: string;
error?: string | { code?: string; message?: string };
}
| null;
export class AuthApiError extends Error {
status: number;
code: string | null;
body: unknown;
constructor(message: string, status: number, body: unknown, code: string | null = null) {
super(message);
this.name = "AuthApiError";
this.status = status;
this.code = code;
this.body = body;
}
}
function toSession(value: unknown): AuthSession | null {
const direct = authSessionSchema.safeParse(value);
if (direct.success) return direct.data;
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const sessionValue = record.session;
const userValue = record.user;
if (!sessionValue || typeof sessionValue !== "object") return null;
if (!userValue || typeof userValue !== "object") return null;
const session = sessionValue as Record<string, unknown>;
const user = userValue as Record<string, unknown>;
if (typeof session.id !== "string" || typeof session.userId !== "string") return null;
if (typeof user.id !== "string") return null;
return {
session: { id: session.id, userId: session.userId },
user: {
id: user.id,
email: typeof user.email === "string" ? user.email : null,
name: typeof user.name === "string" ? user.name : null,
},
};
const nested = authSessionSchema.safeParse((value as Record<string, unknown>).data);
return nested.success ? nested.data : null;
}
function extractAuthError(payload: AuthErrorBody, status: number) {
const nested =
payload?.error && typeof payload.error === "object"
? payload.error
: null;
const code =
typeof nested?.code === "string"
? nested.code
: typeof payload?.code === "string"
? payload.code
: null;
const message =
typeof nested?.message === "string" && nested.message.trim().length > 0
? nested.message
: typeof payload?.message === "string" && payload.message.trim().length > 0
? payload.message
: typeof payload?.error === "string" && payload.error.trim().length > 0
? payload.error
: `Request failed: ${status}`;
return new AuthApiError(message, status, payload, code);
}
async function authPost(path: string, body: Record<string, unknown>) {
@@ -33,16 +69,25 @@ async function authPost(path: string, body: Record<string, unknown>) {
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
const message =
(payload as { error?: { message?: string } | string } | null)?.error &&
typeof (payload as { error?: { message?: string } | string }).error === "object"
? ((payload as { error?: { message?: string } }).error?.message ?? `Request failed: ${res.status}`)
: (payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`;
throw new Error(message);
throw extractAuthError(payload as AuthErrorBody, res.status);
}
return payload;
}
async function authPatch<T>(path: string, body: Record<string, unknown>, parse: (value: unknown) => T): Promise<T> {
const res = await fetch(`/api/auth${path}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
throw extractAuthError(payload as AuthErrorBody, res.status);
}
return parse(payload);
}
export const authApi = {
getSession: async (): Promise<AuthSession | null> => {
const res = await fetch("/api/auth/get-session", {
@@ -68,6 +113,21 @@ export const authApi = {
await authPost("/sign-up/email", input);
},
getProfile: async (): Promise<CurrentUserProfile> => {
const res = await fetch("/api/auth/profile", {
credentials: "include",
headers: { Accept: "application/json" },
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
throw new Error((payload as { error?: string } | null)?.error ?? `Failed to load profile (${res.status})`);
}
return currentUserProfileSchema.parse(payload);
},
updateProfile: async (input: UpdateCurrentUserProfile): Promise<CurrentUserProfile> =>
authPatch("/profile", input, (payload) => currentUserProfileSchema.parse(payload)),
signOut: async () => {
await authPost("/sign-out", {});
},

View File

@@ -4,6 +4,7 @@ import { timeAgo } from "../lib/timeAgo";
import { cn } from "../lib/utils";
import { formatActivityVerb } from "../lib/activity-format";
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
import type { CompanyUserProfile } from "../lib/company-members";
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
switch (entityType) {
@@ -19,13 +20,14 @@ function entityLink(entityType: string, entityId: string, name?: string | null):
interface ActivityRowProps {
event: ActivityEvent;
agentMap: Map<string, Agent>;
userProfileMap?: Map<string, CompanyUserProfile>;
entityNameMap: Map<string, string>;
entityTitleMap?: Map<string, string>;
className?: string;
}
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
const verb = formatActivityVerb(event.action, event.details, { agentMap });
export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
const verb = formatActivityVerb(event.action, event.details, { agentMap, userProfileMap });
const isHeartbeatEvent = event.entityType === "heartbeat_run";
const heartbeatAgentId = isHeartbeatEvent
@@ -43,13 +45,16 @@ export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, cl
: entityLink(event.entityType, event.entityId, name);
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown");
const userProfile = event.actorType === "user" ? userProfileMap?.get(event.actorId) : null;
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : userProfile?.label ?? (event.actorType === "user" ? "Board" : event.actorId || "Unknown"));
const actorAvatarUrl = userProfile?.image ?? null;
const inner = (
<div className="flex gap-3">
<p className="flex-1 min-w-0 truncate">
<Identity
name={actorName}
avatarUrl={actorAvatarUrl}
size="xs"
className="align-baseline"
/>

View File

@@ -0,0 +1,114 @@
import { Navigate, Outlet, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { accessApi } from "@/api/access";
import { authApi } from "@/api/auth";
import { healthApi } from "@/api/health";
import { queryKeys } from "@/lib/queryKeys";
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">Instance setup required</h1>
<p className="mt-2 text-sm text-muted-foreground">
{hasActiveInvite
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
</p>
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
{`pnpm paperclipai auth bootstrap-ceo`}
</pre>
</div>
</div>
);
}
function NoBoardAccessPage() {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">No company access</h1>
<p className="mt-2 text-sm text-muted-foreground">
This account is signed in, but it does not have an active company membership or instance-admin access on
this Paperclip instance.
</p>
<p className="mt-2 text-sm text-muted-foreground">
Use a company invite or sign in with an account that already belongs to this org.
</p>
</div>
</div>
);
}
export function CloudAccessGate() {
const location = useLocation();
const healthQuery = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
const data = query.state.data as
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
| undefined;
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
? 2000
: false;
},
refetchIntervalInBackground: true,
});
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
const sessionQuery = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
enabled: isAuthenticatedMode,
retry: false,
});
const boardAccessQuery = useQuery({
queryKey: queryKeys.access.currentBoardAccess,
queryFn: () => accessApi.getCurrentBoardAccess(),
enabled: isAuthenticatedMode && !!sessionQuery.data,
retry: false,
});
if (
healthQuery.isLoading ||
(isAuthenticatedMode && sessionQuery.isLoading) ||
(isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading)
) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
if (healthQuery.error || boardAccessQuery.error) {
return (
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
{healthQuery.error instanceof Error
? healthQuery.error.message
: boardAccessQuery.error instanceof Error
? boardAccessQuery.error.message
: "Failed to load app state"}
</div>
);
}
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
}
if (isAuthenticatedMode && !sessionQuery.data) {
const next = encodeURIComponent(`${location.pathname}${location.search}`);
return <Navigate to={`/auth?next=${next}`} replace />;
}
if (
isAuthenticatedMode &&
sessionQuery.data &&
!boardAccessQuery.data?.isInstanceAdmin &&
(boardAccessQuery.data?.companyIds.length ?? 0) === 0
) {
return <NoBoardAccessPage />;
}
return <Outlet />;
}

View File

@@ -13,6 +13,7 @@ interface CompanyPatternIconProps {
logoUrl?: string | null;
brandColor?: string | null;
className?: string;
logoFit?: "cover" | "contain";
}
function hashString(value: string): number {
@@ -165,6 +166,7 @@ export function CompanyPatternIcon({
logoUrl,
brandColor,
className,
logoFit = "cover",
}: CompanyPatternIconProps) {
const initial = companyName.trim().charAt(0).toUpperCase() || "?";
const [imageError, setImageError] = useState(false);
@@ -189,7 +191,10 @@ export function CompanyPatternIcon({
src={logo}
alt={`${companyName} logo`}
onError={() => setImageError(true)}
className="absolute inset-0 h-full w-full object-cover"
className={cn(
"absolute inset-0 h-full w-full",
logoFit === "contain" ? "object-contain" : "object-cover",
)}
/>
) : patternDataUrl ? (
<img

View File

@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockSidebarBadgesApi = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
onClick,
}: {
children: React.ReactNode;
to: string;
onClick?: () => void;
}) => (
<button type="button" data-to={to} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({
isMobile: false,
setSidebarOpen: vi.fn(),
}),
}));
vi.mock("./SidebarNavItem", () => ({
SidebarNavItem: (props: {
to: string;
label: string;
end?: boolean;
badge?: number;
}) => {
sidebarNavItemMock(props);
return <div>{props.label}</div>;
},
}));
vi.mock("@/api/sidebarBadges", () => ({
sidebarBadgesApi: mockSidebarBadgesApi,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("CompanySettingsSidebar", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockSidebarBadgesApi.get.mockResolvedValue({
inbox: 0,
approvals: 0,
failedRuns: 0,
joinRequests: 2,
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("renders the company back link and the settings sections in the sidebar", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanySettingsSidebar />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Paperclip");
expect(container.textContent).toContain("Company Settings");
expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Invites");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings",
label: "General",
end: true,
}),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/access",
label: "Access",
badge: 2,
end: true,
}),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/invites",
label: "Invites",
end: true,
}),
);
await act(async () => {
root.unmount();
});
});
});

View File

@@ -0,0 +1,69 @@
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, MailPlus, Settings, Shield, SlidersHorizontal } from "lucide-react";
import { sidebarBadgesApi } from "@/api/sidebarBadges";
import { ApiError } from "@/api/client";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { SidebarNavItem } from "./SidebarNavItem";
export function CompanySettingsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { isMobile, setSidebarOpen } = useSidebar();
const { data: badges } = useQuery({
queryKey: selectedCompanyId
? queryKeys.sidebarBadges(selectedCompanyId)
: ["sidebar-badges", "__disabled__"] as const,
queryFn: async () => {
try {
return await sidebarBadgesApi.get(selectedCompanyId!);
} catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return null;
}
throw error;
}
},
enabled: !!selectedCompanyId,
retry: false,
refetchInterval: 15_000,
});
return (
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
<Link
to="/dashboard"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedCompany?.name ?? "Company"}</span>
</Link>
<div className="flex items-center gap-2 px-2 py-1">
<Settings className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 truncate text-sm font-bold text-foreground">
Company Settings
</span>
</div>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem
to="/company/settings/access"
label="Access"
icon={Shield}
badge={badges?.joinRequests ?? 0}
end
/>
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
</div>
</nav>
</aside>
);
}

View File

@@ -1,6 +1,10 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import type { Agent, Issue } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
import { sortAgentsByRecency, getRecentAssigneeIds } from "../lib/recent-assignees";
import {
buildExecutionPolicy,
@@ -34,14 +38,27 @@ export function ExecutionParticipantPicker({
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
const values = stageType === "review" ? reviewerValues : approverValues;
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(issue.companyId),
queryFn: () => accessApi.listUserDirectory(issue.companyId),
enabled: !!issue.companyId,
});
const sortedAgents = sortAgentsByRecency(
agents.filter((a) => a.status !== "terminated"),
getRecentAssigneeIds(),
);
const userLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
const otherUserOptions = useMemo(
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
[companyMembers?.users, currentUserId, issue.createdByUserId],
);
const userLabel = (userId: string | null | undefined) =>
formatAssigneeUserLabel(userId, currentUserId);
formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
const creatorUserLabel = userLabel(issue.createdByUserId);
const agentName = (id: string) => {
@@ -138,6 +155,24 @@ export function ExecutionParticipantPicker({
{creatorUserLabel ?? "Requester"}
</button>
)}
{otherUserOptions
.filter((option) => {
if (!search.trim()) return true;
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase());
})
.map((option) => (
<button
key={option.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
values.includes(option.id) && "bg-accent",
)}
onClick={() => toggle(option.id)}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{option.label}
</button>
))}
{sortedAgents
.filter((agent) => {
if (!search.trim()) return true;

View File

@@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
}}>
<DialogContent className="max-h-[85vh] overflow-x-hidden overflow-y-auto p-4 sm:max-w-2xl sm:p-6 [&>*]:min-w-0">
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{actionLabel}</DialogTitle>
<DialogDescription className="break-words text-xs sm:text-sm">
<DialogDescription className="break-words">
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
</DialogDescription>
</DialogHeader>
{readinessQuery.isLoading ? (
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Checking whether this workspace is safe to close...
</div>
) : readinessQuery.error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-destructive">
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
</div>
) : readiness ? (
<div className="min-w-0 space-y-3 sm:space-y-4">
<div className={`rounded-xl border px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm ${readinessTone(readiness.state)}`}>
<div className="space-y-4">
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
<div className="font-medium">
{readiness.state === "blocked"
? "Close is blocked"
@@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({
{blockingIssues.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Blocking issues</h3>
<div className="space-y-1.5 sm:space-y-2">
<h3 className="text-sm font-medium">Blocking issues</h3>
<div className="space-y-2">
{blockingIssues.map((issue) => (
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title}
@@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.blockingReasons.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Blocking reasons</h3>
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
{readiness.blockingReasons.map((reason, idx) => (
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 sm:px-3 sm:py-2 text-destructive">
<h3 className="text-sm font-medium">Blocking reasons</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
{readiness.blockingReasons.map((reason) => (
<li key={reason} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive">
{reason}
</li>
))}
@@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.warnings.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Warnings</h3>
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
{readiness.warnings.map((warning, idx) => (
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-2.5 py-1.5 sm:px-3 sm:py-2">
<h3 className="text-sm font-medium">Warnings</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
{readiness.warnings.map((warning) => (
<li key={warning} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
{warning}
</li>
))}
@@ -173,16 +173,16 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.git ? (
<section className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Git status</h3>
<div className="overflow-hidden rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="grid grid-cols-2 gap-2">
<div className="min-w-0">
<h3 className="text-sm font-medium">Git status</h3>
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="grid gap-2 sm:grid-cols-2">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
<div className="truncate font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
</div>
<div className="min-w-0">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
<div className="truncate font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
@@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({
{otherLinkedIssues.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Other linked issues</h3>
<div className="space-y-1.5 sm:space-y-2">
<h3 className="text-sm font-medium">Other linked issues</h3>
<div className="space-y-2">
{otherLinkedIssues.map((issue) => (
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title}
@@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.runtimeServices.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Attached runtime services</h3>
<div className="space-y-1.5 sm:space-y-2">
<h3 className="text-sm font-medium">Attached runtime services</h3>
<div className="space-y-2">
{readiness.runtimeServices.map((service) => (
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className="font-medium">{service.serviceName}</span>
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
@@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({
) : null}
<section className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Cleanup actions</h3>
<div className="space-y-1.5 sm:space-y-2">
<h3 className="text-sm font-medium">Cleanup actions</h3>
<div className="space-y-2">
{readiness.plannedActions.map((action, index) => (
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="font-medium">{action.label}</div>
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
{action.command ? (
@@ -262,20 +262,20 @@ export function ExecutionWorkspaceCloseDialog({
</section>
{currentStatus === "cleanup_failed" ? (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground">
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
workspace status if it succeeds.
</div>
) : null}
{currentStatus === "archived" ? (
<div className="rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
This workspace is already archived.
</div>
) : null}
{readiness.git?.repoRoot ? (
<div className="overflow-hidden break-words text-xs text-muted-foreground">
<div className="break-words text-xs text-muted-foreground">
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
{readiness.git.workspacePath ? (
<>

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
@@ -23,7 +23,9 @@ export function InstanceSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings/profile" label="Profile" icon={UserRoundPen} end />
<SidebarNavItem to="/instance/settings/general" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem to="/instance/settings/access" label="Access" icon={Shield} end />
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />

View File

@@ -5,7 +5,12 @@ import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueChatThread, canStopIssueChatRun, resolveAssistantMessageFoldedState } from "./IssueChatThread";
import {
IssueChatThread,
canStopIssueChatRun,
resolveAssistantMessageFoldedState,
resolveIssueChatHumanAuthor,
} from "./IssueChatThread";
const { markdownEditorFocusMock } = vi.hoisted(() => ({
markdownEditorFocusMock: vi.fn(),
@@ -661,4 +666,33 @@ describe("IssueChatThread", () => {
activeRunIds: new Set<string>(),
})).toBe(false);
});
it("uses company profile data to distinguish the current user from other humans", () => {
const userProfileMap = new Map([
["user-1", { label: "Dotta", image: "/avatars/dotta.png" }],
["user-2", { label: "Alice", image: "/avatars/alice.png" }],
]);
expect(resolveIssueChatHumanAuthor({
authorName: "You",
authorUserId: "user-1",
currentUserId: "user-1",
userProfileMap,
})).toEqual({
isCurrentUser: true,
authorName: "Dotta",
avatarUrl: "/avatars/dotta.png",
});
expect(resolveIssueChatHumanAuthor({
authorName: "Alice",
authorUserId: "user-2",
currentUserId: "user-1",
userProfileMap,
})).toEqual({
isCurrentUser: false,
authorName: "Alice",
avatarUrl: "/avatars/alice.png",
});
});
});

View File

@@ -47,7 +47,7 @@ import {
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
@@ -74,6 +74,7 @@ import {
shouldPreserveComposerViewport,
} from "../lib/issue-chat-scroll";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { CompanyUserProfile } from "../lib/company-members";
import { timeAgo } from "../lib/timeAgo";
import {
describeToolInput,
@@ -96,6 +97,8 @@ interface IssueChatMessageContext {
feedbackTermsUrl: string | null;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
activeRunIds: ReadonlySet<string>;
onVote?: (
commentId: string,
@@ -217,6 +220,8 @@ interface IssueChatThreadProps {
issueStatus?: string;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
@@ -477,12 +482,13 @@ function formatTimelineAssigneeLabel(
assignee: IssueTimelineAssignee,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
userLabelMap?: ReadonlyMap<string, string> | null,
) {
if (assignee.agentId) {
return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8);
}
if (assignee.userId) {
return formatAssigneeUserLabel(assignee.userId, currentUserId) ?? "Board";
return formatAssigneeUserLabel(assignee.userId, currentUserId, userLabelMap) ?? "Board";
}
return "Unassigned";
}
@@ -495,6 +501,26 @@ function initialsForName(name: string) {
return name.slice(0, 2).toUpperCase();
}
export function resolveIssueChatHumanAuthor(args: {
authorName?: string | null;
authorUserId?: string | null;
currentUserId?: string | null;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
}) {
const { authorName, authorUserId, currentUserId, userProfileMap } = args;
const profile = authorUserId ? userProfileMap?.get(authorUserId) ?? null : null;
const isCurrentUser = Boolean(authorUserId && currentUserId && authorUserId === currentUserId);
const resolvedAuthorName = profile?.label?.trim()
|| authorName?.trim()
|| (authorUserId === "local-board" ? "Board" : (isCurrentUser ? "You" : "User"));
return {
isCurrentUser,
authorName: resolvedAuthorName,
avatarUrl: profile?.image ?? null,
};
}
function formatRunStatusLabel(status: string) {
switch (status) {
case "timed_out":
@@ -906,108 +932,151 @@ function IssueChatToolPart({
}
function IssueChatUserMessage() {
const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
const {
onInterruptQueued,
onCancelQueued,
interruptingQueuedRunId,
currentUserId,
userProfileMap,
} = useContext(IssueChatCtx);
const message = useMessage();
const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id;
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null;
const queued = custom.queueState === "queued" || custom.clientStatus === "queued";
const pending = custom.clientStatus === "pending";
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
const [copied, setCopied] = useState(false);
const {
isCurrentUser,
authorName: resolvedAuthorName,
avatarUrl,
} = resolveIssueChatHumanAuthor({
authorName,
authorUserId,
currentUserId,
userProfileMap,
});
const authorAvatar = (
<Avatar size="sm" className="mt-1 shrink-0">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={resolvedAuthorName} /> : null}
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
</Avatar>
);
const messageBody = (
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
</div>
<div
className={cn(
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
queued
? "bg-amber-50/80 dark:bg-amber-500/10"
: "bg-muted",
pending && "opacity-80",
)}
>
{queued ? (
<div className="mb-1.5 flex items-center gap-2">
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
Queued
</span>
{queueTargetRunId && onInterruptQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
disabled={interruptingQueuedRunId === queueTargetRunId}
onClick={() => void onInterruptQueued(queueTargetRunId)}
>
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
{onCancelQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
onClick={() => onCancelQueued(commentId)}
>
Cancel
</Button>
) : null}
</div>
) : null}
<div className="min-w-0 max-w-full space-y-3">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => <IssueChatTextPart text={text} />,
}}
/>
</div>
</div>
{pending ? (
<div className={cn("mt-1 flex px-1 text-[11px] text-muted-foreground", isCurrentUser ? "justify-end" : "justify-start")}>
Sending...
</div>
) : (
<div
className={cn(
"mt-1 flex items-center gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100",
isCurrentUser ? "justify-end" : "justify-start",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
const text = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
)}
</div>
);
return (
<MessagePrimitive.Root id={anchorId}>
<div className="group flex items-start justify-end gap-2.5">
<div className="flex min-w-0 max-w-[85%] flex-col items-end">
<div
className={cn(
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
queued
? "bg-amber-50/80 dark:bg-amber-500/10"
: "bg-muted",
pending && "opacity-80",
)}
>
{queued ? (
<div className="mb-1.5 flex items-center gap-2">
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
Queued
</span>
{queueTargetRunId && onInterruptQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
disabled={interruptingQueuedRunId === queueTargetRunId}
onClick={() => void onInterruptQueued(queueTargetRunId)}
>
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
{onCancelQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
onClick={() => onCancelQueued(commentId)}
>
Cancel
</Button>
) : null}
</div>
) : null}
<div className="min-w-0 max-w-full space-y-3">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => <IssueChatTextPart text={text} />,
}}
/>
</div>
</div>
{pending ? (
<div className="mt-1 flex justify-end px-1 text-[11px] text-muted-foreground">Sending...</div>
) : (
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
const text = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
)}
</div>
<Avatar size="sm" className="mt-1 shrink-0">
<AvatarFallback>You</AvatarFallback>
</Avatar>
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
{isCurrentUser ? (
<>
{messageBody}
{authorAvatar}
</>
) : (
<>
{authorAvatar}
{messageBody}
</>
)}
</div>
</MessagePrimitive.Root>
);
@@ -1463,7 +1532,7 @@ function IssueChatFeedbackButtons({
}
function IssueChatSystemMessage() {
const { agentMap, currentUserId } = useContext(IssueChatCtx);
const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx);
const message = useMessage();
const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
@@ -1519,11 +1588,11 @@ function IssueChatSystemMessage() {
Assignee
</span>
<span className="text-muted-foreground">
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId)}
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId, userLabelMap)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="font-medium text-foreground">
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId)}
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId, userLabelMap)}
</span>
</div>
) : null}
@@ -1855,6 +1924,8 @@ export function IssueChatThread({
issueStatus,
agentMap,
currentUserId,
userLabelMap,
userProfileMap,
onVote,
onAdd,
onCancelRun,
@@ -1947,6 +2018,7 @@ export function IssueChatThread({
projectId,
agentMap,
currentUserId,
userLabelMap,
}),
[
comments,
@@ -1961,6 +2033,7 @@ export function IssueChatThread({
projectId,
agentMap,
currentUserId,
userLabelMap,
],
);
const stableMessagesRef = useRef<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
@@ -2028,6 +2101,8 @@ export function IssueChatThread({
feedbackTermsUrl,
agentMap,
currentUserId,
userLabelMap,
userProfileMap,
activeRunIds,
onVote,
onStopRun,
@@ -2043,6 +2118,8 @@ export function IssueChatThread({
feedbackTermsUrl,
agentMap,
currentUserId,
userLabelMap,
userProfileMap,
activeRunIds,
onVote,
onStopRun,

View File

@@ -196,6 +196,8 @@ export function InboxIssueTrailingColumns({
workspaceId,
workspaceName,
assigneeName,
assigneeUserName,
assigneeUserAvatarUrl,
currentUserId,
parentIdentifier,
parentTitle,
@@ -209,6 +211,8 @@ export function InboxIssueTrailingColumns({
workspaceId?: string | null;
workspaceName: string | null;
assigneeName: string | null;
assigneeUserName?: string | null;
assigneeUserAvatarUrl?: string | null;
currentUserId: string | null;
parentIdentifier: string | null;
parentTitle: string | null;
@@ -216,7 +220,7 @@ export function InboxIssueTrailingColumns({
onFilterWorkspace?: (workspaceId: string) => void;
}) {
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
const userLabel = assigneeUserName ?? formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
return (
<span
@@ -243,8 +247,13 @@ export function InboxIssueTrailingColumns({
if (issue.assigneeUserId) {
return (
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
{userLabel}
<span key={column} className="min-w-0 text-xs text-foreground">
<Identity
name={userLabel}
avatarUrl={assigneeUserAvatarUrl}
size="sm"
className="min-w-0"
/>
</span>
);
}

View File

@@ -3,12 +3,14 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link } from "@/lib/router";
import type { Issue } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { formatAssigneeUserLabel } from "../lib/assignees";
@@ -187,6 +189,11 @@ export function IssueProperties({
queryFn: () => agentsApi.list(companyId!),
enabled: !!companyId,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(companyId!),
queryFn: () => accessApi.listUserDirectory(companyId!),
enabled: !!companyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(companyId!),
@@ -257,13 +264,21 @@ export function IssueProperties({
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
[agents, recentAssigneeIds],
);
const userLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
const otherUserOptions = useMemo(
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
[companyMembers?.users, currentUserId, issue.createdByUserId],
);
const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId)
: null;
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
const assigneeUserLabel = userLabel(issue.assigneeUserId);
const creatorUserLabel = userLabel(issue.createdByUserId);
const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => {
@@ -499,6 +514,31 @@ export function IssueProperties({
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
</button>
)}
{otherUserOptions
.filter((option) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(q);
})
.map((option) => {
const userId = option.id.slice("user:".length);
return (
<button
key={option.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === userId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: userId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{option.label}
</button>
);
})}
{sortedAgents
.filter((a) => {
if (!assigneeSearch.trim()) return true;
@@ -571,6 +611,24 @@ export function IssueProperties({
{creatorUserLabel ? creatorUserLabel : "Requester"}
</button>
)}
{otherUserOptions
.filter((option) => {
if (!search.trim()) return true;
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase());
})
.map((option) => (
<button
key={`${stageType}:${option.id}`}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
values.includes(option.id) && "bg-accent",
)}
onClick={() => toggleExecutionParticipant(stageType, option.id)}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{option.label}
</button>
))}
{sortedAgents
.filter((agent) => {
if (!search.trim()) return true;

View File

@@ -26,6 +26,11 @@ const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockAccessApi = vi.hoisted(() => ({
listMembers: vi.fn(),
listUserDirectory: vi.fn(),
}));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
list: vi.fn(),
listSummaries: vi.fn(),
@@ -51,6 +56,10 @@ vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../api/access", () => ({
accessApi: mockAccessApi,
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: mockExecutionWorkspacesApi,
}));
@@ -183,12 +192,16 @@ describe("IssuesList", () => {
mockIssuesApi.list.mockReset();
mockIssuesApi.listLabels.mockReset();
mockAuthApi.getSession.mockReset();
mockAccessApi.listMembers.mockReset();
mockAccessApi.listUserDirectory.mockReset();
mockExecutionWorkspacesApi.list.mockReset();
mockExecutionWorkspacesApi.listSummaries.mockReset();
mockInstanceSettingsApi.getExperimental.mockReset();
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
mockAccessApi.listMembers.mockResolvedValue({ members: [], access: {} });
mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] });
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
@@ -498,6 +511,50 @@ describe("IssuesList", () => {
});
});
it("shows human assignee names from company member profiles", async () => {
localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"]));
mockAccessApi.listUserDirectory.mockResolvedValue({
users: [
{
principalId: "user-2",
status: "active",
user: {
id: "user-2",
name: "Jordan Lee",
email: "jordan@example.com",
image: "https://example.com/jordan.png",
},
},
],
});
const assignedIssue = createIssue({
id: "issue-human",
identifier: "PAP-12",
title: "Human assigned issue",
assigneeUserId: "user-2",
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[assignedIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(container.textContent).toContain("Jordan Lee");
});
act(() => {
root.unmount();
});
});
it("preserves stored grouping across refresh when initial assignees are applied", async () => {
localStorage.setItem(
"paperclip:test-issues:company-1",

View File

@@ -1,5 +1,6 @@
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { executionWorkspacesApi } from "../api/execution-workspaces";
@@ -12,6 +13,7 @@ import {
shouldBlurPageSearchOnEscape,
} from "../lib/keyboardShortcuts";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
import { groupBy } from "../lib/groupBy";
import {
applyIssueFilters,
@@ -282,6 +284,11 @@ export function IssuesList({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
@@ -376,6 +383,15 @@ export function IssuesList({
return agents.find((a) => a.id === id)?.name ?? null;
}, [agents]);
const companyUserLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
const companyUserProfileMap = useMemo(
() => buildCompanyUserProfileMap(companyMembers?.users),
[companyMembers?.users],
);
const projectById = useMemo(() => {
const map = new Map<string, { name: string; color: string | null }>();
for (const project of projects ?? []) {
@@ -601,11 +617,11 @@ export function IssuesList({
key === "__unassigned"
? "Unassigned"
: key.startsWith("__user:")
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User")
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId, companyUserLabelMap) ?? "User")
: (agentName(key) ?? key.slice(0, 8)),
items: groups[key]!,
}));
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap, companyUserLabelMap]);
useEffect(() => {
if (viewState.viewMode !== "list") return;
@@ -910,6 +926,14 @@ export function IssuesList({
const useDeferredRowRendering = !(hasChildren && isExpanded);
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
const assigneeUserProfile = issue.assigneeUserId
? companyUserProfileMap.get(issue.assigneeUserId) ?? null
: null;
const assigneeUserLabel = formatAssigneeUserLabel(
issue.assigneeUserId,
currentUserId,
companyUserLabelMap,
) ?? assigneeUserProfile?.label ?? null;
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
e.preventDefault();
e.stopPropagation();
@@ -994,6 +1018,8 @@ export function IssuesList({
})}
onFilterWorkspace={filterToWorkspace}
assigneeName={agentName(issue.assigneeAgentId)}
assigneeUserName={assigneeUserLabel}
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
currentUserId={currentUserId}
parentIdentifier={parentIssue?.identifier ?? null}
parentTitle={parentIssue?.title ?? null}
@@ -1007,18 +1033,18 @@ export function IssuesList({
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
className="flex w-full shrink-0 items-center overflow-hidden rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" className="min-w-0" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3.5 w-3.5" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
<Identity
name={assigneeUserLabel ?? "User"}
avatarUrl={assigneeUserProfile?.image ?? null}
size="sm"
className="min-w-0"
/>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">

View File

@@ -0,0 +1,249 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Layout } from "./Layout";
const mockHealthApi = vi.hoisted(() => ({
get: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getGeneral: vi.fn(),
}));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
let currentPathname = "/PAP/dashboard";
vi.mock("@/lib/router", () => ({
Outlet: () => <div>Outlet content</div>,
useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }),
useNavigate: () => mockNavigate,
useNavigationType: () => "PUSH",
useParams: () => ({ companyPrefix: "PAP" }),
}));
vi.mock("./CompanyRail", () => ({
CompanyRail: () => <div>Company rail</div>,
}));
vi.mock("./Sidebar", () => ({
Sidebar: () => <div>Main company nav</div>,
}));
vi.mock("./InstanceSidebar", () => ({
InstanceSidebar: () => <div>Instance sidebar</div>,
}));
vi.mock("./CompanySettingsSidebar", () => ({
CompanySettingsSidebar: () => <div>Company settings sidebar</div>,
}));
vi.mock("./BreadcrumbBar", () => ({
BreadcrumbBar: () => <div>Breadcrumbs</div>,
}));
vi.mock("./PropertiesPanel", () => ({
PropertiesPanel: () => null,
}));
vi.mock("./CommandPalette", () => ({
CommandPalette: () => null,
}));
vi.mock("./NewIssueDialog", () => ({
NewIssueDialog: () => null,
}));
vi.mock("./NewProjectDialog", () => ({
NewProjectDialog: () => null,
}));
vi.mock("./NewGoalDialog", () => ({
NewGoalDialog: () => null,
}));
vi.mock("./NewAgentDialog", () => ({
NewAgentDialog: () => null,
}));
vi.mock("./KeyboardShortcutsCheatsheet", () => ({
KeyboardShortcutsCheatsheet: () => null,
}));
vi.mock("./ToastViewport", () => ({
ToastViewport: () => null,
}));
vi.mock("./MobileBottomNav", () => ({
MobileBottomNav: () => null,
}));
vi.mock("./WorktreeBanner", () => ({
WorktreeBanner: () => null,
}));
vi.mock("./DevRestartBanner", () => ({
DevRestartBanner: () => null,
}));
vi.mock("./SidebarAccountMenu", () => ({
SidebarAccountMenu: () => <div>Account menu</div>,
}));
vi.mock("../context/DialogContext", () => ({
useDialog: () => ({
openNewIssue: vi.fn(),
openOnboarding: vi.fn(),
}),
}));
vi.mock("../context/PanelContext", () => ({
usePanel: () => ({
togglePanelVisible: vi.fn(),
}),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
loading: false,
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
selectedCompanyId: "company-1",
selectionSource: "manual",
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
sidebarOpen: true,
setSidebarOpen: mockSetSidebarOpen,
toggleSidebar: vi.fn(),
isMobile: false,
}),
}));
vi.mock("../hooks/useKeyboardShortcuts", () => ({
useKeyboardShortcuts: () => undefined,
}));
vi.mock("../hooks/useCompanyPageMemory", () => ({
useCompanyPageMemory: () => undefined,
}));
vi.mock("../api/health", () => ({
healthApi: mockHealthApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../lib/company-selection", () => ({
shouldSyncCompanySelectionFromRoute: () => false,
}));
vi.mock("../lib/instance-settings", () => ({
DEFAULT_INSTANCE_SETTINGS_PATH: "/instance/settings/general",
normalizeRememberedInstanceSettingsPath: (value: string | null | undefined) =>
value ?? "/instance/settings/general",
}));
vi.mock("../lib/main-content-focus", () => ({
scheduleMainContentFocus: () => () => undefined,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("Layout", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
currentPathname = "/PAP/dashboard";
mockHealthApi.get.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
version: "1.2.3",
});
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
keyboardShortcuts: false,
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("does not render the deployment explainer in the shared layout", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockHealthApi.get).toHaveBeenCalled();
expect(container.textContent).toContain("Breadcrumbs");
expect(container.textContent).toContain("Outlet content");
expect(container.textContent).not.toContain("Authenticated private");
expect(container.textContent).not.toContain(
"Sign-in is required and this instance is intended for private-network access.",
);
await act(async () => {
root.unmount();
});
});
it("renders the company settings sidebar on company settings routes", async () => {
currentPathname = "/PAP/company/settings/access";
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Company settings sidebar");
expect(container.textContent).not.toContain("Instance sidebar");
expect(container.textContent).not.toContain("Main company nav");
await act(async () => {
root.unmount();
});
});
});

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
import { Link, Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
import { InstanceSidebar } from "./InstanceSidebar";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
import { BreadcrumbBar } from "./BreadcrumbBar";
import { PropertiesPanel } from "./PropertiesPanel";
import { CommandPalette } from "./CommandPalette";
@@ -17,12 +17,12 @@ import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { SidebarAccountMenu } from "./SidebarAccountMenu";
import { useDialog } from "../context/DialogContext";
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { useTheme } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
@@ -34,15 +34,12 @@ import {
} from "../lib/instance-settings";
import {
resetNavigationScroll,
SIDEBAR_SCROLL_RESET_STATE,
shouldResetScrollOnNavigation,
} from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { scheduleMainContentFocus } from "../lib/main-content-focus";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
@@ -67,12 +64,12 @@ export function Layout() {
selectionSource,
setSelectedCompanyId,
} = useCompany();
const { theme, toggleTheme } = useTheme();
const { companyPrefix } = useParams<{ companyPrefix: string }>();
const navigate = useNavigate();
const location = useLocation();
const navigationType = useNavigationType();
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
const isCompanySettingsRoute = location.pathname.includes("/company/settings");
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const previousPathname = useRef<string | null>(null);
@@ -80,7 +77,6 @@ export function Layout() {
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const nextTheme = theme === "dark" ? "light" : "dark";
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
@@ -341,53 +337,19 @@ export function Layout() {
>
<div className="flex flex-1 min-h-0 overflow-hidden">
<CompanyRail />
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<div className="flex items-center gap-1">
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
{health?.version && (
<Tooltip>
<TooltipTrigger asChild>
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
</TooltipTrigger>
<TooltipContent>v{health.version}</TooltipContent>
</Tooltip>
)}
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to={instanceSettingsTarget}
state={SIDEBAR_SCROLL_RESET_STATE}
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Settings className="h-4 w-4" />
</Link>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
{isInstanceSettingsRoute ? (
<InstanceSidebar />
) : isCompanySettingsRoute ? (
<CompanySettingsSidebar />
) : (
<Sidebar />
)}
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
instanceSettingsTarget={instanceSettingsTarget}
version={health?.version}
/>
</div>
) : (
<div className="flex h-full flex-col shrink-0">
@@ -399,54 +361,20 @@ export function Layout() {
sidebarOpen ? "w-60" : "w-0"
)}
>
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
</div>
<div className="border-t border-r border-border px-3 py-2">
<div className="flex items-center gap-1">
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
{health?.version && (
<Tooltip>
<TooltipTrigger asChild>
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
</TooltipTrigger>
<TooltipContent>v{health.version}</TooltipContent>
</Tooltip>
{isInstanceSettingsRoute ? (
<InstanceSidebar />
) : isCompanySettingsRoute ? (
<CompanySettingsSidebar />
) : (
<Sidebar />
)}
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to={instanceSettingsTarget}
state={SIDEBAR_SCROLL_RESET_STATE}
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Settings className="h-4 w-4" />
</Link>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
instanceSettingsTarget={instanceSettingsTarget}
version={health?.version}
/>
</div>
)}

View File

@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, buildUserMentionHref } from "@paperclipai/shared";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
import { queryKeys } from "../lib/queryKeys";
@@ -75,17 +75,19 @@ describe("MarkdownBody", () => {
expect(html).toContain('alt="Org chart"');
});
it("renders agent, project, and skill mentions as chips", () => {
it("renders user, agent, project, and skill mentions as chips", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody>
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
{`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
expect(html).toContain('href="/company/settings/access"');
expect(html).toContain('data-mention-kind="user"');
expect(html).toContain('href="/agents/agent-123"');
expect(html).toContain('data-mention-kind="agent"');
expect(html).toContain("--paperclip-mention-icon-mask");

Some files were not shown because too many files have changed in this diff Show More