Port advanced company access UX to EE permissions plugin

Adds members + roles/grants/scopes editor to the Paperclip EE permissions
plugin company settings page so EE can edit member access through capability-
gated SDK methods. Companion test harness improvements let plugin tests seed
members and grants and observe persisted writes through `access.members.*`
and `authorization.grants.*`.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-22 01:51:03 +00:00
1 parent 022616389c
commit cdee5ea706
5 files changed
+711 -18

No files matched your search

@@ -6,6 +6,70 @@ import {
type PluginCompanySettingsPageProps,
} from "@paperclipai/plugin-sdk/ui";
type HumanCompanyMembershipRole = "owner" | "admin" | "operator" | "viewer";
type MemberEditableStatus = "pending" | "active" | "suspended";
type MemberPrincipalType = "user" | "agent";
type PermissionKey =
| "agents:create"
| "environments:manage"
| "users:invite"
| "users:manage_permissions"
| "tasks:assign"
| "tasks:assign_scope"
| "tasks:manage_active_checkouts"
| "joins:approve";
const HUMAN_ROLE_LABELS: Record<HumanCompanyMembershipRole, string> = {
owner: "Owner",
admin: "Admin",
operator: "Operator",
viewer: "Viewer",
};
const MEMBER_PERMISSION_KEYS: ReadonlyArray<PermissionKey> = [
"agents:create",
"users:invite",
"users:manage_permissions",
"tasks:assign",
"tasks:manage_active_checkouts",
"joins:approve",
"environments:manage",
];
const PERMISSION_LABELS: Record<PermissionKey, string> = {
"agents:create": "Create agents",
"users:invite": "Invite humans and agents",
"users:manage_permissions": "Manage members and grants",
"tasks:assign": "Assign tasks",
"tasks:assign_scope": "Assign scoped tasks",
"tasks:manage_active_checkouts": "Manage active task checkouts",
"joins:approve": "Approve join requests",
"environments:manage": "Manage environments",
};
const IMPLICIT_ROLE_GRANTS: Record<HumanCompanyMembershipRole, PermissionKey[]> = {
owner: ["agents:create", "users:invite", "users:manage_permissions", "tasks:assign", "joins:approve"],
admin: ["agents:create", "users:invite", "tasks:assign", "joins:approve"],
operator: ["tasks:assign"],
viewer: [],
};
type MemberRecord = {
id: string;
companyId: string;
principalType: MemberPrincipalType;
principalId: string;
status: MemberEditableStatus | "archived";
membershipRole: HumanCompanyMembershipRole | string | null;
grants: Array<{ permissionKey: string; scope: Record<string, unknown> | null }>;
};
type MemberAccessData = {
companyId: string;
warnings: Array<{ code: string; message: string }>;
members: MemberRecord[];
};
type LicenseState = {
status: "active" | "inactive";
activatedAt?: string;
@@ -212,9 +276,15 @@ function getErrorMessage(error: unknown): string {
function CapabilityWarning({ warnings }: { warnings: Overview["warnings"] }) {
if (warnings.length === 0) return null;
const denied = warnings.some((warning) => warning.code === "CAPABILITY_DENIED");
return (
<div style={warningStyle}>
<strong>Some advanced data could not be loaded.</strong>
<strong>{denied ? "Some advanced data is unavailable." : "Some advanced data could not be loaded."}</strong>
<div style={mutedTextStyle}>
{denied
? "The plugin is missing one or more capability grants. Install the latest version or re-activate the plugin to restore the missing surfaces."
: "Existing restrictions remain enforced by core. Retry once the underlying service is reachable."}
</div>
<ul style={{ margin: "6px 0 0", paddingLeft: "18px" }}>
{warnings.map((warning, index) => (
<li key={`${warning.code}-${index}`}>
@@ -283,6 +353,253 @@ function getPolicyBoolean(policy: Record<string, unknown> | null | undefined, se
return typeof value === "boolean" ? value : fallback;
}
function MembersPanel({ companyId }: { companyId: string }) {
const query = usePluginData<MemberAccessData>("memberAccess", { companyId });
const saveMemberAccess = usePluginAction("saveMemberAccess");
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
const [draftRole, setDraftRole] = useState<HumanCompanyMembershipRole | "">("");
const [draftStatus, setDraftStatus] = useState<MemberEditableStatus>("active");
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const members = query.data?.members ?? [];
const editingMember = useMemo(
() => members.find((member) => member.id === editingMemberId) ?? null,
[members, editingMemberId],
);
const implicitGrantKeys = useMemo<PermissionKey[]>(
() => (draftRole ? IMPLICIT_ROLE_GRANTS[draftRole] : []),
[draftRole],
);
useEffect(() => {
if (!editingMember) return;
const role = editingMember.membershipRole;
setDraftRole((role && role in HUMAN_ROLE_LABELS ? (role as HumanCompanyMembershipRole) : ""));
setDraftStatus(
editingMember.status === "active" || editingMember.status === "pending" || editingMember.status === "suspended"
? editingMember.status
: "suspended",
);
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey as PermissionKey)));
setError(null);
}, [editingMember]);
if (query.loading && !query.data) {
return <div style={mutedTextStyle}>Loading members...</div>;
}
if (query.error) {
return (
<div style={warningStyle}>
<strong>Could not load company members.</strong>
<div>{query.error.message}</div>
</div>
);
}
const humanMembers = members.filter((member) => member.principalType === "user");
const agentMembers = members.filter((member) => member.principalType === "agent");
const closeDialog = () => {
setEditingMemberId(null);
setError(null);
};
async function save() {
if (!editingMember) return;
setBusy(true);
setError(null);
try {
await saveMemberAccess({
companyId,
memberId: editingMember.id,
membershipRole: draftRole || null,
status: draftStatus,
grants: [...draftGrants],
});
query.refresh();
setEditingMemberId(null);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div style={layoutStack}>
<CapabilityWarning warnings={query.data?.warnings ?? []} />
<div style={cardStyle}>
<div style={rowStyle}>
<div style={sectionHeadingStyle}>Members</div>
<button type="button" style={buttonStyle} onClick={() => query.refresh()}>Refresh</button>
</div>
<div style={mutedTextStyle}>
Roles, status, and explicit permission grants for users and agents in this company. Saves through capability-gated plugin SDK calls; data lives in core.
</div>
{humanMembers.length === 0 && agentMembers.length === 0 ? (
<div style={mutedTextStyle}>No company members yet.</div>
) : null}
<MembersTable members={humanMembers} label="Humans" emptyLabel="No human members" onEdit={setEditingMemberId} />
<MembersTable members={agentMembers} label="Agents" emptyLabel="No agent members" onEdit={setEditingMemberId} />
</div>
{editingMember ? (
<div style={cardStyle} role="dialog" aria-label="Edit member">
<div style={rowStyle}>
<div style={sectionHeadingStyle}>Edit member</div>
<Pill label={editingMember.principalType} />
</div>
<div style={mutedTextStyle}>
Principal <code>{editingMember.principalId}</code>
</div>
<div style={gridStyle}>
<label style={{ display: "grid", gap: "6px" }}>
<span style={sectionHeadingStyle}>Company role</span>
<select
style={inputStyle}
value={draftRole}
onChange={(event) => setDraftRole(event.target.value as HumanCompanyMembershipRole | "")}
>
<option value="">Unset</option>
{Object.entries(HUMAN_ROLE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
<label style={{ display: "grid", gap: "6px" }}>
<span style={sectionHeadingStyle}>Membership status</span>
<select
style={inputStyle}
value={draftStatus}
onChange={(event) => setDraftStatus(event.target.value as MemberEditableStatus)}
>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="suspended">Suspended</option>
</select>
</label>
</div>
<div style={subtleCardStyle}>
<div style={rowStyle}>
<strong>Implicit grants from role</strong>
{draftRole ? <Pill label={HUMAN_ROLE_LABELS[draftRole]} /> : <Pill label="No role" />}
</div>
<div style={mutedTextStyle}>
{draftRole
? `${HUMAN_ROLE_LABELS[draftRole]} already includes these permissions automatically.`
: "No role selected, so this member has no implicit grants right now."}
</div>
{implicitGrantKeys.length > 0 ? (
<div style={rowStyle}>
{implicitGrantKeys.map((permissionKey) => (
<Pill key={permissionKey} label={PERMISSION_LABELS[permissionKey]} />
))}
</div>
) : null}
</div>
<div>
<div style={sectionHeadingStyle}>Explicit grants</div>
<div style={mutedTextStyle}>
Explicit grants persist even when the role changes. Scoped assignment grants are managed in the policy editor below.
</div>
<div style={{ ...gridStyle, marginTop: "8px" }}>
{MEMBER_PERMISSION_KEYS.map((permissionKey) => {
const isImplicit = implicitGrantKeys.includes(permissionKey);
const isChecked = draftGrants.has(permissionKey);
return (
<label key={permissionKey} style={subtleCardStyle}>
<label style={{ ...rowStyle, gap: "10px" }}>
<input
type="checkbox"
checked={isChecked}
onChange={(event) => {
setDraftGrants((current) => {
const next = new Set(current);
if (event.target.checked) next.add(permissionKey);
else next.delete(permissionKey);
return next;
});
}}
/>
<span>
<strong>{PERMISSION_LABELS[permissionKey]}</strong>
<div style={{ ...mutedTextStyle, fontSize: "0.72rem" }}>
<code>{permissionKey}</code>
</div>
</span>
</label>
{isImplicit && !isChecked ? (
<div style={mutedTextStyle}>Included implicitly by the {draftRole ? HUMAN_ROLE_LABELS[draftRole] : "selected"} role.</div>
) : null}
{isChecked ? (
<div style={mutedTextStyle}>Stored explicitly for this member.</div>
) : null}
</label>
);
})}
</div>
</div>
{error ? <div style={warningStyle}><strong>Could not save:</strong> {error}</div> : null}
<div style={rowStyle}>
<button type="button" style={buttonStyle} disabled={busy} onClick={closeDialog}>
Cancel
</button>
<button type="button" style={primaryButtonStyle} disabled={busy} onClick={() => void save()}>
{busy ? "Saving..." : "Save access"}
</button>
</div>
</div>
) : null}
</div>
);
}
function MembersTable({
members,
label,
emptyLabel,
onEdit,
}: {
members: MemberRecord[];
label: string;
emptyLabel: string;
onEdit: (memberId: string) => void;
}) {
return (
<div style={subtleCardStyle}>
<div style={rowStyle}>
<strong>{label}</strong>
<Pill label={`${members.length}`} />
</div>
{members.length === 0 ? (
<div style={mutedTextStyle}>{emptyLabel}</div>
) : (
<div style={{ display: "grid", gap: "6px" }}>
{members.map((member) => (
<div key={member.id} style={{ ...rowStyle, justifyContent: "space-between" }}>
<div style={{ display: "grid", gap: "2px" }}>
<strong>{member.principalId}</strong>
<div style={mutedTextStyle}>
Role <code>{member.membershipRole ?? "unset"}</code> • Status <code>{member.status}</code> • {member.grants.length} grant{member.grants.length === 1 ? "" : "s"}
</div>
</div>
<button type="button" style={buttonStyle} onClick={() => onEdit(member.id)}>Edit</button>
</div>
))}
</div>
)}
</div>
);
}
function AdvancedPolicyEditor({ companyId }: { companyId: string }) {
const saveAgentPolicy = usePluginAction("saveAgentPolicy");
const saveAssignmentGrant = usePluginAction("saveAssignmentGrant");
@@ -617,6 +934,7 @@ export function EePermissionsCompanySettingsPage(_props: PluginCompanySettingsPa
</div>
</div>
<MembersPanel companyId={companyId} />
<AdvancedPolicyEditor companyId={companyId} />
</div>
);
@@ -2,6 +2,8 @@ import {
definePlugin,
runWorker,
PLUGIN_RPC_ERROR_CODES,
type HumanCompanyMembershipRole,
type MembershipStatus,
type PluginAccessMember,
type PluginAuthorizationAuditEntry,
type PluginAuthorizationPolicySummary,
@@ -43,6 +45,22 @@ export type EePermissionsOverview = {
type AssignmentGrantMode = "broad" | "scoped_agent" | "clear";
const HUMAN_ROLE_VALUES: readonly HumanCompanyMembershipRole[] = ["owner", "admin", "operator", "viewer"];
const MEMBER_EDITABLE_STATUSES: ReadonlyArray<Extract<MembershipStatus, "pending" | "active" | "suspended">> = [
"pending",
"active",
"suspended",
];
const NON_MEMBER_PERMISSION_KEYS: ReadonlySet<PermissionKey> = new Set([
"tasks:assign_scope",
]);
export type EePermissionsMemberAccessData = {
companyId: string;
warnings: Array<{ code: string; message: string }>;
members: PluginAccessMember[];
};
export type EePermissionsAdvancedPolicyData = {
companyId: string;
summary: PluginAuthorizationPolicySummary | null;
@@ -93,6 +111,33 @@ function readGrantMode(value: unknown): AssignmentGrantMode {
return "scoped_agent";
}
function readHumanRole(value: unknown): HumanCompanyMembershipRole | null {
if (value === null) return null;
if (typeof value !== "string") return null;
if (value === "") return null;
return (HUMAN_ROLE_VALUES as readonly string[]).includes(value)
? (value as HumanCompanyMembershipRole)
: null;
}
function readEditableStatus(value: unknown): Extract<MembershipStatus, "pending" | "active" | "suspended"> {
if (typeof value === "string" && (MEMBER_EDITABLE_STATUSES as readonly string[]).includes(value)) {
return value as Extract<MembershipStatus, "pending" | "active" | "suspended">;
}
return "active";
}
function readPermissionKeyArray(value: unknown): PermissionKey[] {
if (!Array.isArray(value)) return [];
const out: PermissionKey[] = [];
for (const entry of value) {
if (typeof entry === "string" && !out.includes(entry as PermissionKey)) {
out.push(entry as PermissionKey);
}
}
return out;
}
function withoutAssignmentGrants(grants: PrincipalPermissionGrant[]) {
return grants
.filter((grant) => grant.permissionKey !== "tasks:assign" && grant.permissionKey !== "tasks:assign_scope")
@@ -177,6 +222,26 @@ const plugin = definePlugin({
}
});
ctx.data.register("memberAccess", async (params) => {
const companyId = readCompanyId(params ?? {});
const license = await readLicense(ctx, companyId);
if (license.status !== "active") {
return {
companyId,
warnings: [],
members: [],
} satisfies EePermissionsMemberAccessData;
}
const warnings: EePermissionsMemberAccessData["warnings"] = [];
let members: PluginAccessMember[] = [];
try {
members = await ctx.access.members.list({ companyId });
} catch (error) {
warnings.push(formatError(error));
}
return { companyId, warnings, members } satisfies EePermissionsMemberAccessData;
});
ctx.data.register("grants", async (params) => {
const input = params ?? {};
const companyId = readCompanyId(input);
@@ -373,6 +438,56 @@ const plugin = definePlugin({
return DEFAULT_LICENSE;
});
ctx.actions.register("saveMemberAccess", async (params) => {
const input = params ?? {};
const companyId = readCompanyId(input);
const license = await readLicense(ctx, companyId);
if (license.status !== "active") {
throw new Error("Paperclip EE permissions mode is not active for this company");
}
const memberId = readString(input, "memberId");
if (!memberId) throw new Error("memberId is required");
const member = await ctx.access.members.get(memberId, companyId);
if (!member) throw new Error(`Membership not found: ${memberId}`);
const membershipRole = readHumanRole(input.membershipRole);
const status = readEditableStatus(input.status);
const explicitGrants = readPermissionKeyArray(input.grants)
.filter((key) => !NON_MEMBER_PERMISSION_KEYS.has(key));
const updatedMember = await ctx.access.members.update(
memberId,
{ membershipRole, status },
companyId,
);
const existing = await ctx.authorization.grants.list({
companyId,
principalType: member.principalType,
principalId: member.principalId,
});
const carriedOverNonMemberGrants = existing
.filter((grant) => NON_MEMBER_PERMISSION_KEYS.has(grant.permissionKey as PermissionKey))
.map((grant) => ({
permissionKey: grant.permissionKey as PermissionKey,
scope: grant.scope && typeof grant.scope === "object" ? grant.scope as Record<string, unknown> : null,
}));
const nextGrants = [
...explicitGrants.map((permissionKey) => ({ permissionKey, scope: null as Record<string, unknown> | null })),
...carriedOverNonMemberGrants,
];
const grants = await ctx.authorization.grants.set({
companyId,
principalType: member.principalType,
principalId: member.principalId,
grants: nextGrants,
});
return {
member: { ...updatedMember, grants },
};
});
ctx.actions.register("saveAgentPolicy", async (params) => {
const input = params ?? {};
const companyId = readCompanyId(input);
@@ -4,6 +4,7 @@ import manifest, { EXPORT_NAMES, SLOT_IDS } from "../src/manifest.js";
import plugin, {
type EePermissionsAdvancedPolicyData,
type EePermissionsLicense,
type EePermissionsMemberAccessData,
type EePermissionsOverview,
} from "../src/worker.js";
@@ -11,6 +12,8 @@ const COMPANY_ID = "00000000-0000-4000-8000-000000000001";
const ACTOR_AGENT_ID = "00000000-0000-4000-8000-0000000000a1";
const TARGET_AGENT_ID = "00000000-0000-4000-8000-0000000000a2";
const ISSUE_ID = "00000000-0000-4000-8000-0000000000b1";
const MEMBER_USER_ID = "00000000-0000-4000-8000-0000000000c1";
const MEMBER_RECORD_ID = "00000000-0000-4000-8000-0000000000d1";
describe("paperclip-ee-permissions manifest", () => {
it("declares the company settings page slot", () => {
@@ -215,7 +218,169 @@ describe("paperclip-ee-permissions worker", () => {
agentVisibility: { mode: "private" },
protectedAgent: { requiresApproval: true },
});
expect(grants).toEqual([]);
expect(grants).toEqual([
{
principalType: "agent",
principalId: ACTOR_AGENT_ID,
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: TARGET_AGENT_ID },
},
]);
});
it("returns empty member access while unlicensed", async () => {
const harness = createTestHarness({
manifest,
capabilities: manifest.capabilities,
});
await plugin.definition.setup(harness.ctx);
const data = await harness.getData<EePermissionsMemberAccessData>("memberAccess", {
companyId: COMPANY_ID,
});
expect(data.companyId).toBe(COMPANY_ID);
expect(data.members).toEqual([]);
expect(data.warnings).toEqual([]);
});
it("loads seeded company members once licensed", async () => {
const harness = createTestHarness({
manifest,
capabilities: manifest.capabilities,
});
harness.seed({
accessMembers: [
{
id: MEMBER_RECORD_ID,
companyId: COMPANY_ID,
principalType: "user",
principalId: MEMBER_USER_ID,
status: "active",
membershipRole: "operator",
grants: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
principalGrants: [
{
id: "grant-1",
companyId: COMPANY_ID,
principalType: "user",
principalId: MEMBER_USER_ID,
permissionKey: "users:invite",
scope: null,
grantedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
await plugin.definition.setup(harness.ctx);
await harness.performAction("activateLicense", { companyId: COMPANY_ID });
const data = await harness.getData<EePermissionsMemberAccessData>("memberAccess", {
companyId: COMPANY_ID,
});
expect(data.members).toHaveLength(1);
const [member] = data.members;
expect(member.principalId).toBe(MEMBER_USER_ID);
expect(member.membershipRole).toBe("operator");
expect(member.grants.map((grant) => grant.permissionKey)).toEqual(["users:invite"]);
});
it("saves member role, status, and grants atomically", async () => {
const harness = createTestHarness({
manifest,
capabilities: manifest.capabilities,
});
harness.seed({
accessMembers: [
{
id: MEMBER_RECORD_ID,
companyId: COMPANY_ID,
principalType: "user",
principalId: MEMBER_USER_ID,
status: "active",
membershipRole: "viewer",
grants: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
principalGrants: [
// pre-existing scoped grant should survive a member-level save
{
id: "grant-scoped",
companyId: COMPANY_ID,
principalType: "user",
principalId: MEMBER_USER_ID,
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: TARGET_AGENT_ID },
grantedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
await plugin.definition.setup(harness.ctx);
await harness.performAction("activateLicense", { companyId: COMPANY_ID });
const result = await harness.performAction<{
member: EePermissionsMemberAccessData["members"][number];
}>("saveMemberAccess", {
companyId: COMPANY_ID,
memberId: MEMBER_RECORD_ID,
membershipRole: "operator",
status: "active",
grants: ["users:invite", "tasks:assign"],
});
expect(result.member.membershipRole).toBe("operator");
expect(result.member.status).toBe("active");
const grantKeys = result.member.grants.map((grant) => grant.permissionKey).sort();
expect(grantKeys).toEqual(["tasks:assign", "tasks:assign_scope", "users:invite"]);
const reread = await harness.getData<EePermissionsMemberAccessData>("memberAccess", {
companyId: COMPANY_ID,
});
const [updated] = reread.members;
expect(updated.membershipRole).toBe("operator");
expect(updated.grants.map((grant) => grant.permissionKey).sort()).toEqual([
"tasks:assign",
"tasks:assign_scope",
"users:invite",
]);
});
it("refuses to save member access while unlicensed", async () => {
const harness = createTestHarness({
manifest,
capabilities: manifest.capabilities,
});
harness.seed({
accessMembers: [
{
id: MEMBER_RECORD_ID,
companyId: COMPANY_ID,
principalType: "user",
principalId: MEMBER_USER_ID,
status: "active",
membershipRole: "viewer",
grants: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
});
await plugin.definition.setup(harness.ctx);
await expect(
harness.performAction("saveMemberAccess", {
companyId: COMPANY_ID,
memberId: MEMBER_RECORD_ID,
membershipRole: "operator",
status: "active",
grants: [],
}),
).rejects.toThrow(/permissions mode is not active/);
});
it("rejects requests that omit companyId", async () => {
+7
View File
@@ -268,6 +268,8 @@ export type {
PrincipalPermissionGrant,
PrincipalType,
PluginDatabaseClient,
HumanCompanyMembershipRole,
MembershipStatus,
} from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared
@@ -375,4 +377,9 @@ export {
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
PLUGIN_EVENT_TYPES,
PLUGIN_BRIDGE_ERROR_CODES,
PERMISSION_KEYS,
HUMAN_COMPANY_MEMBERSHIP_ROLES,
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
MEMBERSHIP_STATUSES,
PRINCIPAL_TYPES,
} from "@paperclipai/shared";
+104 -16
View File
@@ -38,6 +38,10 @@ import type {
AgentSessionEvent,
PluginLocalFolderEntry,
PluginLocalFolderStatus,
PluginAccessMember,
PrincipalPermissionGrant,
PermissionKey,
PrincipalType,
} from "./types.js";
import type {
PluginEnvironmentValidateConfigParams,
@@ -73,7 +77,7 @@ export interface TestHarnessLogEntry {
export interface TestHarness {
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
ctx: PluginContext;
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
/** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */
seed(input: {
companies?: Company[];
projects?: Project[];
@@ -83,6 +87,8 @@ export interface TestHarness {
goals?: Goal[];
projectWorkspaces?: PluginWorkspace[];
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
accessMembers?: PluginAccessMember[];
principalGrants?: PrincipalPermissionGrant[];
}): void;
setConfig(config: Record<string, unknown>): void;
/** Dispatch a host or plugin event to registered handlers. */
@@ -440,6 +446,39 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const issueDocuments = new Map<string, IssueDocument>();
const agents = new Map<string, Agent>();
const goals = new Map<string, Goal>();
const accessMembers = new Map<string, PluginAccessMember>();
const principalGrants = new Map<string, PrincipalPermissionGrant[]>();
function principalGrantsKey(companyId: string, principalType: PrincipalType, principalId: string) {
return `${companyId}:${principalType}:${principalId}`;
}
function getPrincipalGrants(companyId: string, principalType: PrincipalType, principalId: string) {
return principalGrants.get(principalGrantsKey(companyId, principalType, principalId)) ?? [];
}
function setPrincipalGrants(
companyId: string,
principalType: PrincipalType,
principalId: string,
grants: Array<{ permissionKey: PermissionKey; scope?: Record<string, unknown> | null }>,
) {
const stamped = grants.map((grant) => ({
principalType,
principalId,
permissionKey: grant.permissionKey,
scope: grant.scope && typeof grant.scope === "object" ? grant.scope : null,
})) as PrincipalPermissionGrant[];
principalGrants.set(principalGrantsKey(companyId, principalType, principalId), stamped);
const member = [...accessMembers.values()].find(
(entry) =>
entry.companyId === companyId
&& entry.principalType === principalType
&& entry.principalId === principalId,
);
if (member) {
accessMembers.set(member.id, { ...member, grants: stamped, updatedAt: new Date().toISOString() });
}
return stamped;
}
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
@@ -1987,18 +2026,44 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
members: {
async list(input) {
requireCapability(manifest, capabilitySet, "access.members.read");
requireCompanyId(input.companyId);
return [];
const cid = requireCompanyId(input.companyId);
const includeArchived = input.includeArchived === true;
return [...accessMembers.values()]
.filter((member) => member.companyId === cid)
.filter((member) => includeArchived || member.status !== ("archived" as PluginAccessMember["status"]))
.map((member) => ({
...member,
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
}));
},
async get(_memberId, companyId) {
async get(memberId, companyId) {
requireCapability(manifest, capabilitySet, "access.members.read");
requireCompanyId(companyId);
return null;
const cid = requireCompanyId(companyId);
const member = accessMembers.get(memberId);
if (!member || member.companyId !== cid) return null;
return {
...member,
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
};
},
async update(memberId, _patch, companyId) {
async update(memberId, patch, companyId) {
requireCapability(manifest, capabilitySet, "access.members.write");
requireCompanyId(companyId);
throw new Error(`Membership not found: ${memberId}`);
const cid = requireCompanyId(companyId);
const member = accessMembers.get(memberId);
if (!member || member.companyId !== cid) {
throw new Error(`Membership not found: ${memberId}`);
}
const updated: PluginAccessMember = {
...member,
membershipRole: patch.membershipRole === undefined ? member.membershipRole : patch.membershipRole,
status: patch.status === undefined ? member.status : patch.status,
updatedAt: new Date().toISOString(),
};
accessMembers.set(memberId, updated);
return {
...updated,
grants: getPrincipalGrants(cid, updated.principalType, updated.principalId),
};
},
},
invites: {
@@ -2023,25 +2088,42 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
grants: {
async list(input) {
requireCapability(manifest, capabilitySet, "authorization.grants.read");
requireCompanyId(input.companyId);
return [];
const cid = requireCompanyId(input.companyId);
if (input.principalType && input.principalId) {
return getPrincipalGrants(cid, input.principalType, input.principalId);
}
const out: PrincipalPermissionGrant[] = [];
for (const [key, grants] of principalGrants.entries()) {
if (!key.startsWith(`${cid}:`)) continue;
for (const grant of grants) {
if (input.principalType && grant.principalType !== input.principalType) continue;
if (input.principalId && grant.principalId !== input.principalId) continue;
out.push(grant);
}
}
return out;
},
async set(input) {
requireCapability(manifest, capabilitySet, "authorization.grants.write");
requireCompanyId(input.companyId);
return [];
const cid = requireCompanyId(input.companyId);
return setPrincipalGrants(cid, input.principalType, input.principalId, input.grants);
},
},
policies: {
async summary(companyId) {
requireCapability(manifest, capabilitySet, "authorization.policies.read");
const cid = requireCompanyId(companyId);
const members = [...accessMembers.values()].filter((member) => member.companyId === cid);
let grantCount = 0;
for (const [key, grants] of principalGrants.entries()) {
if (key.startsWith(`${cid}:`)) grantCount += grants.length;
}
return {
companyId: cid,
permissionsMode: "simple",
memberCount: 0,
activeMemberCount: 0,
grantCount: 0,
memberCount: members.length,
activeMemberCount: members.filter((member) => member.status === "active").length,
grantCount,
advancedPolicyAvailable: false,
};
},
@@ -2172,6 +2254,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
projectWorkspaces.set(row.projectId, list);
}
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
for (const row of input.accessMembers ?? []) accessMembers.set(row.id, row);
for (const row of input.principalGrants ?? []) {
const list = principalGrants.get(principalGrantsKey(row.companyId, row.principalType, row.principalId)) ?? [];
list.push(row);
principalGrants.set(principalGrantsKey(row.companyId, row.principalType, row.principalId), list);
}
},
setConfig(config) {
currentConfig = { ...config };