Files
PaperClipAI/server/src/__tests__/invite-join-grants.test.ts
T
DottaandPaperclip e5927e1db9 fix: give commentary its own default read permission (#16903)
## Thinking Path

> - Paperclip lets people manage AI agents and inspect their work.
> - Commentary collects agent suggestions and complaints for company
members.
> - Commentary used the permission for detailed agent-action audits.
> - Owners, admins and viewers could therefore receive a permission
error when they opened Commentary.
> - This pull request gives commentary its own permission and enables it
for all human company roles.
> - Members can read feedback while task privacy and detailed audit
access keep their own checks.

## Linked Issues or Issue Description

Refs #16884, which added the Commentary browser. A search found no open
PR for this permission fix.

**What happened?**

Commentary returned `Missing permission: audit:view_agent_actions` for
company members without the detailed audit grant. Default owners, admins
and viewers did not receive that grant.

**Expected behavior**

All human company roles can read commentary by default. A separate
`commentary:view` permission controls this access. Private source tasks
remain private. Commentary access does not grant detailed agent-action
audits or CSV exports.

**Steps to reproduce**

1. Sign in as an active company owner, admin or viewer without
`audit:view_agent_actions`.
2. Open Audit, then Commentary.
3. Observe the permission error before this change.

**Paperclip version or commit**

Base commit: `7c58ea07b2`.

**Deployment mode**

Authenticated server or Cloud tenant.

## What Changed

- Add `commentary:view` to the shared permission contract and all human
role defaults.
- Require that permission for commentary list and detail reads.
- Add an idempotent upgrade migration for existing active human members.
Preserve existing grants and scopes.
- Add the commentary default when approving saved human invitation
grants that omit it, including pre-upgrade invitations. Preserve
explicit commentary scopes.
- Add regression coverage for role defaults, permission removal, audit
separation, private tasks and migration replay.
- Update the API description, specifications, database notes and access
maps.

## Verification

- Passed 68 tests across commentary reads, role grants, old-invitation
HTTP acceptance, migration replay, agent audit routes, access services
and migration snapshot drift under the repository's locked dependencies.
- Confirmed the new role and read tests failed before the
implementation.
- `node scripts/check-maps.mjs` passed.
- Migration numbering and safety checks passed during the build.
- Full `pnpm -r typecheck` and `pnpm build` passed locally after the
invitation fix.
- The final-commit CI matrix passed, including all test shards,
typecheck, build, public-install checks and canary smoke. The redundant
local `pnpm test:run` was stopped after the full CI matrix completed;
the 68 focused local regressions passed separately.
- The first install bypassed the stale lockfile and selected a newer
diff-viewer API. The initial typecheck and build failed in that
unrelated plugin. Installing the locked versions cleared both failures;
the tracked lockfile is unchanged.
- The first locked-dependency regression run hit the 15-second limit
while importing the audit route graph in its first test. Moving that
import into suite setup fixed the timeout; all 58 regressions then
passed.
- Greptile found that pre-upgrade invitations retain old grant lists.
New helper and HTTP regressions reproduced this gap before the fix, then
passed after approval added the missing commentary default.
- Greptile reviewed the final commit at 5/5 with no remaining actionable
findings. Its earlier thread is resolved.
- No UI code changed. The permission tests use real disposable
PostgreSQL databases and HTTP routes.

## Risks

- The upgrade deliberately enables commentary for every existing active
human company member, including viewers. Existing commentary grants
retain their saved scopes.
- Source-task privacy still filters previews, counts, searches and full
messages. Agent-authenticated browsing remains denied.
- Agent-action audit grants, detailed filters and CSV export permissions
keep their current behavior.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository editing and
command execution. The exact runtime model ID and context window are not
exposed to this session.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-19 16:58:17 -05:00

182 lines
6.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
agentJoinGrantsFromDefaults,
humanJoinGrantsFromDefaults,
} from "../services/invite-grants.js";
import {
grantsForHumanRole,
normalizeHumanRole,
resolveHumanInviteRole,
} from "../services/company-member-roles.js";
import { NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, newStandardAgentGrantScope } from "../lib/agent-permissions.js";
const invitedAgentId = "agent-1";
const defaultAgentGrants = NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS.map((permissionKey) => ({
permissionKey,
scope: newStandardAgentGrantScope(permissionKey, invitedAgentId),
}));
describe("agentJoinGrantsFromDefaults", () => {
it("adds all new-agent grants when invite defaults do not specify grants", () => {
expect(agentJoinGrantsFromDefaults(null, invitedAgentId)).toEqual(defaultAgentGrants);
});
it("preserves invite agent grants and appends the missing defaults", () => {
expect(
agentJoinGrantsFromDefaults({
agent: {
grants: [
{
permissionKey: "agents:create",
scope: null,
},
],
},
}, invitedAgentId),
).toEqual([{ permissionKey: "agents:create", scope: null }, ...defaultAgentGrants]);
});
it("does not duplicate tasks:assign when invite defaults already include it", () => {
expect(
agentJoinGrantsFromDefaults({
agent: {
grants: [
{
permissionKey: "tasks:assign",
scope: { projectId: "project-1" },
},
],
},
}, invitedAgentId),
).toEqual([
{ permissionKey: "tasks:assign", scope: { projectId: "project-1" } },
...defaultAgentGrants.filter((grant) => grant.permissionKey !== "tasks:assign"),
]);
});
it("preserves an explicit scoped agent configuration grant", () => {
expect(agentJoinGrantsFromDefaults({
agent: { grants: [{ permissionKey: "agents:configure", scope: { agentIds: ["agent-1"] } }] },
}, invitedAgentId)).toEqual([
{ permissionKey: "agents:configure", scope: { agentIds: ["agent-1"] } },
...defaultAgentGrants.filter((grant) => grant.permissionKey !== "agents:configure"),
]);
});
});
describe("human invite roles", () => {
it("maps owner to the full management grant set", () => {
expect(grantsForHumanRole("owner")).toEqual([
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "agents:configure", scope: null },
{ permissionKey: "skills:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "users:manage_permissions", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
{ permissionKey: "tools:manage_connections", scope: null },
{ permissionKey: "tools:manage_runtime", scope: null },
{ permissionKey: "tools:use", scope: null },
{ permissionKey: "tools:admin", scope: null },
{ permissionKey: "commentary:view", scope: null },
]);
});
it("maps admin to management grants including environment management", () => {
expect(grantsForHumanRole("admin")).toEqual([
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "agents:configure", scope: null },
{ permissionKey: "skills:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "joins:approve", scope: null },
{ permissionKey: "tools:manage_connections", scope: null },
{ permissionKey: "tools:manage_runtime", scope: null },
{ permissionKey: "tools:use", scope: null },
{ permissionKey: "tools:admin", scope: null },
{ permissionKey: "commentary:view", scope: null },
]);
});
it("maps operator to company editing grants without join approval or member permissions", () => {
expect(grantsForHumanRole("operator")).toEqual([
{ permissionKey: "agents:create", scope: null },
{ permissionKey: "agents:configure", scope: null },
{ permissionKey: "skills:create", scope: null },
{ permissionKey: "environments:manage", scope: null },
{ permissionKey: "users:invite", scope: null },
{ permissionKey: "tasks:assign", scope: null },
{ permissionKey: "tools:manage_connections", scope: null },
{ permissionKey: "tools:manage_profiles", scope: null },
{ permissionKey: "tools:manage_runtime", scope: null },
{ permissionKey: "tools:use", scope: null },
{ permissionKey: "tools:admin", scope: null },
{ permissionKey: "tools:view_audit", scope: null },
{ permissionKey: "audit:view_agent_actions", scope: null },
{ permissionKey: "commentary:view", scope: null },
]);
});
it("lets viewers read commentary without granting agent audit access", () => {
expect(grantsForHumanRole("viewer")).toEqual([
{ permissionKey: "commentary:view", scope: null },
]);
expect(humanJoinGrantsFromDefaults(null, "viewer")).toEqual(grantsForHumanRole("viewer"));
});
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(grantsForHumanRole("operator"));
});
it.each(["owner", "admin", "operator", "viewer"] as const)("adds commentary access to saved pre-upgrade %s invite grants", (role) => {
const legacyGrants = grantsForHumanRole(role).filter((grant) => grant.permissionKey !== "commentary:view");
expect(humanJoinGrantsFromDefaults({ human: { role, grants: legacyGrants } }, role)).toEqual(grantsForHumanRole(role));
});
it("preserves an explicitly scoped commentary grant", () => {
const grants = [{ permissionKey: "commentary:view", scope: { agentIds: ["agent-1"] } }];
expect(humanJoinGrantsFromDefaults({ human: { grants } }, "operator")).toEqual(grants);
});
it("preserves explicit human invite scopes and adds the commentary default", () => {
expect(
humanJoinGrantsFromDefaults(
{
human: {
grants: [
{
permissionKey: "users:invite",
scope: { companyId: "company-1" },
},
],
},
},
"operator",
),
).toEqual([
{
permissionKey: "users:invite",
scope: { companyId: "company-1" },
},
{ permissionKey: "commentary:view", scope: null },
]);
});
});