@wopr-network/platform-core 1.17.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/routes/admin-audit-helper.d.ts +1 -1
- package/dist/billing/crypto/evm/__tests__/config.test.js +10 -0
- package/dist/billing/crypto/evm/config.js +12 -0
- package/dist/billing/crypto/evm/types.d.ts +1 -1
- package/dist/fleet/__tests__/rollout-strategy.test.d.ts +1 -0
- package/dist/fleet/__tests__/rollout-strategy.test.js +157 -0
- package/dist/fleet/__tests__/volume-snapshot-manager.test.d.ts +1 -0
- package/dist/fleet/__tests__/volume-snapshot-manager.test.js +171 -0
- package/dist/fleet/index.d.ts +2 -0
- package/dist/fleet/index.js +2 -0
- package/dist/fleet/rollout-strategy.d.ts +52 -0
- package/dist/fleet/rollout-strategy.js +91 -0
- package/dist/fleet/volume-snapshot-manager.d.ts +35 -0
- package/dist/fleet/volume-snapshot-manager.js +185 -0
- package/docs/superpowers/specs/2026-03-14-fleet-auto-update-design.md +300 -0
- package/docs/superpowers/specs/2026-03-14-paperclip-org-integration-design.md +359 -0
- package/docs/superpowers/specs/2026-03-14-role-permissions-design.md +346 -0
- package/package.json +1 -1
- package/src/api/routes/admin-audit-helper.ts +1 -1
- package/src/billing/crypto/evm/__tests__/config.test.ts +12 -0
- package/src/billing/crypto/evm/config.ts +13 -1
- package/src/billing/crypto/evm/types.ts +1 -1
- package/src/fleet/__tests__/rollout-strategy.test.ts +192 -0
- package/src/fleet/__tests__/volume-snapshot-manager.test.ts +218 -0
- package/src/fleet/index.ts +2 -0
- package/src/fleet/rollout-strategy.ts +128 -0
- package/src/fleet/volume-snapshot-manager.ts +213 -0
- package/src/marketplace/volume-installer.test.ts +8 -2
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# Paperclip Platform Org Integration
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-03-14
|
|
4
|
+
**Status:** Draft
|
|
5
|
+
**Repos:** paperclip, paperclip-platform, paperclip-platform-ui, platform-core, platform-ui-core
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
Paperclip (the bot) has a full multi-user org model: companies, memberships, invites, roles, permissions, org charts. The hosted platform layer (paperclip-platform + paperclip-platform-ui) also has an org model: tenants, organization_members, organization_invites.
|
|
10
|
+
|
|
11
|
+
Today the managed Paperclip image runs in `local_trusted` mode, which hardcodes every request as `userId: "local-board"` with instance admin privileges. There is no user identity inside the bot — everyone is the same person.
|
|
12
|
+
|
|
13
|
+
The platform is the front door: it handles auth, billing, and access. Users should manage their team exclusively through the platform. Paperclip's native invite/member UI should be hidden in hosted mode.
|
|
14
|
+
|
|
15
|
+
When a user is invited to a platform org, they should be able to use the Paperclip instance immediately — no second signup, no separate invite flow inside the bot.
|
|
16
|
+
|
|
17
|
+
## Design
|
|
18
|
+
|
|
19
|
+
### Architecture
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Platform (front door)
|
|
23
|
+
├─ Auth (better-auth sessions)
|
|
24
|
+
├─ Org management (invite, roles, billing)
|
|
25
|
+
├─ Proxy (routes requests to Paperclip container)
|
|
26
|
+
│ └─ Injects: x-platform-user-id, x-platform-user-email, x-platform-user-name
|
|
27
|
+
└─ Provisioning (syncs membership changes into Paperclip)
|
|
28
|
+
└─ Calls /internal/add-member, /internal/remove-member
|
|
29
|
+
|
|
30
|
+
Paperclip (the bot, hosted_proxy mode)
|
|
31
|
+
├─ actorMiddleware reads proxy headers → resolves user identity
|
|
32
|
+
├─ Company/invite/member UI hidden in hostedMode
|
|
33
|
+
└─ All org management delegated to platform
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 1. New Deployment Mode: `hosted_proxy`
|
|
37
|
+
|
|
38
|
+
**File:** `paperclip/server/src/middleware/auth.ts`
|
|
39
|
+
|
|
40
|
+
Add a new branch in `actorMiddleware` for `hosted_proxy` deployment mode. Instead of hardcoding `local-board`, read identity from trusted proxy headers:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
if (opts.deploymentMode === "hosted_proxy") {
|
|
44
|
+
const userId = req.header("x-platform-user-id");
|
|
45
|
+
const userEmail = req.header("x-platform-user-email");
|
|
46
|
+
const userName = req.header("x-platform-user-name");
|
|
47
|
+
|
|
48
|
+
if (userId) {
|
|
49
|
+
const [roleRow, memberships] = await Promise.all([
|
|
50
|
+
db.select({ id: instanceUserRoles.id })
|
|
51
|
+
.from(instanceUserRoles)
|
|
52
|
+
.where(and(
|
|
53
|
+
eq(instanceUserRoles.userId, userId),
|
|
54
|
+
eq(instanceUserRoles.role, "instance_admin")
|
|
55
|
+
))
|
|
56
|
+
.then(rows => rows[0] ?? null),
|
|
57
|
+
db.select({ companyId: companyMemberships.companyId })
|
|
58
|
+
.from(companyMemberships)
|
|
59
|
+
.where(and(
|
|
60
|
+
eq(companyMemberships.principalType, "user"),
|
|
61
|
+
eq(companyMemberships.principalId, userId),
|
|
62
|
+
eq(companyMemberships.status, "active"),
|
|
63
|
+
)),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
req.actor = {
|
|
67
|
+
type: "board",
|
|
68
|
+
userId,
|
|
69
|
+
companyIds: memberships.map(r => r.companyId),
|
|
70
|
+
isInstanceAdmin: Boolean(roleRow),
|
|
71
|
+
source: "hosted_proxy",
|
|
72
|
+
};
|
|
73
|
+
} else {
|
|
74
|
+
// No user header = reject (proxy should always inject)
|
|
75
|
+
req.actor = { type: "none", source: "none" };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Dockerfile.managed change:** `PAPERCLIP_DEPLOYMENT_MODE=hosted_proxy` (was `local_trusted`)
|
|
81
|
+
|
|
82
|
+
**Security:** The container is never exposed to the internet. Only the platform proxy can reach it. Trusting headers from the proxy is safe — same pattern as any reverse proxy auth.
|
|
83
|
+
|
|
84
|
+
### 2. Provision Endpoints for Member Management
|
|
85
|
+
|
|
86
|
+
**File:** `paperclip/server/src/routes/provision.ts` (extend existing adapter)
|
|
87
|
+
|
|
88
|
+
The provision adapter already has `ensureUser()` and `grantAccess()`. Add new operations to the adapter interface:
|
|
89
|
+
|
|
90
|
+
**Role mapping (platform → Paperclip):**
|
|
91
|
+
|
|
92
|
+
| Platform Role | Paperclip Role | Instance Admin | Notes |
|
|
93
|
+
|--------------|----------------|----------------|-------|
|
|
94
|
+
| `owner` | `owner` | Yes | Full control |
|
|
95
|
+
| `admin` | `owner` | Yes | Same as owner inside Paperclip — admin distinction is platform-level (billing, org settings) |
|
|
96
|
+
| `member` | `member` | No | Can view/create issues, interact with agents |
|
|
97
|
+
|
|
98
|
+
**Add member:**
|
|
99
|
+
```typescript
|
|
100
|
+
async addMember(companyId: string, user: AdminUser, role: "owner" | "admin" | "member") {
|
|
101
|
+
await this.ensureUser(user);
|
|
102
|
+
const paperclipRole = role === "member" ? "member" : "owner";
|
|
103
|
+
await access.ensureMembership(companyId, "user", user.id, paperclipRole, "active");
|
|
104
|
+
if (paperclipRole === "owner") {
|
|
105
|
+
await access.promoteInstanceAdmin(user.id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Remove member:**
|
|
111
|
+
```typescript
|
|
112
|
+
async removeMember(companyId: string, userId: string) {
|
|
113
|
+
// Remove company membership
|
|
114
|
+
await access.removeMembership(companyId, "user", userId);
|
|
115
|
+
// Demote instance admin if no longer owner of any company
|
|
116
|
+
const remaining = await access.listUserCompanyAccess(userId);
|
|
117
|
+
if (remaining.length === 0) {
|
|
118
|
+
await access.demoteInstanceAdmin(userId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Change role:**
|
|
124
|
+
```typescript
|
|
125
|
+
async changeRole(companyId: string, userId: string, role: "owner" | "admin" | "member") {
|
|
126
|
+
const paperclipRole = role === "member" ? "member" : "owner";
|
|
127
|
+
await access.ensureMembership(companyId, "user", userId, paperclipRole, "active");
|
|
128
|
+
if (paperclipRole === "owner") {
|
|
129
|
+
await access.promoteInstanceAdmin(userId);
|
|
130
|
+
} else {
|
|
131
|
+
// Demotion: remove instance admin if user is no longer owner of any company
|
|
132
|
+
const remaining = await access.listUserCompanyAccess(userId);
|
|
133
|
+
const isOwnerOfAny = remaining.some(c => c.role === "owner");
|
|
134
|
+
if (!isOwnerOfAny) {
|
|
135
|
+
await access.demoteInstanceAdmin(userId);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
These map to new provision-server routes:
|
|
142
|
+
- `POST /internal/members/add` — `{ companyId, user: { id, email, name }, role }`
|
|
143
|
+
- `POST /internal/members/remove` — `{ companyId, userId }`
|
|
144
|
+
- `POST /internal/members/change-role` — `{ companyId, userId, role }`
|
|
145
|
+
|
|
146
|
+
**All provision endpoints are idempotent.** Repeated calls with the same parameters are no-ops (`ensureUser` checks for existing records, `ensureMembership` upserts).
|
|
147
|
+
|
|
148
|
+
All authenticated via `PROVISION_SECRET` bearer token (brand-agnostic; replaces the WOPR-specific `WOPR_PROVISION_SECRET` naming).
|
|
149
|
+
|
|
150
|
+
### 3. Platform Triggers Provisioning on Org Changes
|
|
151
|
+
|
|
152
|
+
**File:** `paperclip-platform/src/trpc/routers/org.ts`
|
|
153
|
+
|
|
154
|
+
When org membership changes happen in the platform, call the Paperclip instance's provision API:
|
|
155
|
+
|
|
156
|
+
**Invite accepted → add member** (triggered from `acceptInvite()` in org.ts — see Section 5):
|
|
157
|
+
```
|
|
158
|
+
Platform: user accepts org invite
|
|
159
|
+
→ acceptInvite() adds user to organization_members
|
|
160
|
+
→ acceptInvite() resolves tenant's Paperclip instance
|
|
161
|
+
→ POST instance:3100/internal/members/add
|
|
162
|
+
{ companyId: <paperclip company id>, user: { id, email, name }, role: "member" }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Member removed → remove member:**
|
|
166
|
+
```
|
|
167
|
+
Platform: admin removes member from org
|
|
168
|
+
→ platform removes from organization_members
|
|
169
|
+
→ POST instance:3100/internal/members/remove
|
|
170
|
+
{ companyId: <paperclip company id>, userId }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Role changed → change role:**
|
|
174
|
+
```
|
|
175
|
+
Platform: admin changes member role
|
|
176
|
+
→ platform updates organization_members
|
|
177
|
+
→ POST instance:3100/internal/members/change-role
|
|
178
|
+
{ companyId: <paperclip company id>, userId, role }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Mapping:** Platform org → Paperclip company. The `companyId` is stored during initial provisioning (already returned by `createTenant()`). Platform stores this mapping in the tenant record.
|
|
182
|
+
|
|
183
|
+
### 4. Hide Company/Invite/Member UI in Hosted Mode
|
|
184
|
+
|
|
185
|
+
**File:** `paperclip/scripts/upstream-sync.mjs`
|
|
186
|
+
|
|
187
|
+
Expand the `infraKeywords` list in `scanForHostedModeGaps()` to include org management patterns:
|
|
188
|
+
|
|
189
|
+
```javascript
|
|
190
|
+
const infraKeywords = [
|
|
191
|
+
// Existing adapter/model keywords...
|
|
192
|
+
"adapterType", "AdapterType", /* ... */
|
|
193
|
+
|
|
194
|
+
// NEW: Org management keywords (hidden in hosted mode)
|
|
195
|
+
"CompanySettings",
|
|
196
|
+
"CompanySwitcher",
|
|
197
|
+
"InviteLanding",
|
|
198
|
+
"createInvite",
|
|
199
|
+
"inviteLink",
|
|
200
|
+
"joinRequest",
|
|
201
|
+
"companyMemberships",
|
|
202
|
+
"boardClaim",
|
|
203
|
+
"BoardClaim",
|
|
204
|
+
"manageMembers",
|
|
205
|
+
"instanceSettings",
|
|
206
|
+
];
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Specific UI changes needed in Paperclip:**
|
|
210
|
+
|
|
211
|
+
| File | Action |
|
|
212
|
+
|------|--------|
|
|
213
|
+
| `ui/src/pages/CompanySettings.tsx` | Hide invite creation, member management sections in hostedMode |
|
|
214
|
+
| `ui/src/components/CompanySwitcher.tsx` | Hide "Manage Companies" link, hide "Company Settings" link in hostedMode |
|
|
215
|
+
| `ui/src/pages/Companies.tsx` | Hide create/delete company in hostedMode (single company, platform-managed) |
|
|
216
|
+
| `ui/src/pages/InviteLanding.tsx` | Redirect to platform in hostedMode |
|
|
217
|
+
| `ui/src/pages/BoardClaim.tsx` | Redirect to platform in hostedMode |
|
|
218
|
+
| `ui/src/components/Layout.tsx` | Hide any "Invite" buttons in hostedMode |
|
|
219
|
+
|
|
220
|
+
**What stays visible:** The Org page (org chart, agent hierarchy) and agent management stay visible — those are product features, not org admin. Users can still see who's on the team and what agents are doing.
|
|
221
|
+
|
|
222
|
+
### 5. Platform Org Gaps to Fix
|
|
223
|
+
|
|
224
|
+
These are existing stubs/gaps in paperclip-platform that need implementation:
|
|
225
|
+
|
|
226
|
+
**`listMyOrganizations()` (org.ts):**
|
|
227
|
+
Currently returns empty. Implement: query `organization_members` for user's memberships, return list of orgs.
|
|
228
|
+
|
|
229
|
+
**Invite acceptance flow:**
|
|
230
|
+
`inviteMember()` creates the invite, but there's no `acceptInvite()` endpoint. Implement:
|
|
231
|
+
1. Validate token, check not expired/revoked
|
|
232
|
+
2. Create user account if needed (signup during accept)
|
|
233
|
+
3. Add to `organization_members`
|
|
234
|
+
4. Call Paperclip provision API to add member (triggers the flow described in Section 3)
|
|
235
|
+
5. Mark invite as accepted
|
|
236
|
+
|
|
237
|
+
**`listMyOrganizations()`:** Currently returns empty in org.ts. Implement by adding `listOrgsForUser(userId)` to platform-core's `OrgService`, then calling it from the tRPC route.
|
|
238
|
+
|
|
239
|
+
**Org switcher in platform UI:**
|
|
240
|
+
`x-tenant-id` header mechanism exists but isn't exposed in the frontend. Add org switcher component that:
|
|
241
|
+
- Lists user's orgs
|
|
242
|
+
- Switches `x-tenant-id` header for subsequent API calls
|
|
243
|
+
- Persists selection to localStorage
|
|
244
|
+
|
|
245
|
+
**Member list population:**
|
|
246
|
+
Org member list loads but doesn't populate user name/email. Join `organization_members` with user table to get display info.
|
|
247
|
+
|
|
248
|
+
### 6. Proxy Header Injection
|
|
249
|
+
|
|
250
|
+
**File:** `paperclip-platform/src/proxy/tenant-proxy.ts`
|
|
251
|
+
|
|
252
|
+
The platform proxy already routes requests to Paperclip containers. Add header injection:
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
// Before proxying to the Paperclip container:
|
|
256
|
+
proxyReq.setHeader("x-platform-user-id", ctx.user.id);
|
|
257
|
+
proxyReq.setHeader("x-platform-user-email", ctx.user.email);
|
|
258
|
+
proxyReq.setHeader("x-platform-user-name", ctx.user.name ?? "");
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Strip these headers from incoming external requests to prevent spoofing:
|
|
262
|
+
```typescript
|
|
263
|
+
// On incoming request, before auth:
|
|
264
|
+
req.headers.delete("x-platform-user-id");
|
|
265
|
+
req.headers.delete("x-platform-user-email");
|
|
266
|
+
req.headers.delete("x-platform-user-name");
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## User Experience
|
|
270
|
+
|
|
271
|
+
### Inviting a Teammate
|
|
272
|
+
|
|
273
|
+
1. User goes to Platform → Settings → Team
|
|
274
|
+
2. Clicks "Invite Member"
|
|
275
|
+
3. Enters email, selects role (admin/member)
|
|
276
|
+
4. Invitee receives email with signup/accept link
|
|
277
|
+
5. Invitee creates platform account (or logs in if existing)
|
|
278
|
+
6. Platform adds them to org + provisions into Paperclip instance
|
|
279
|
+
7. Invitee logs in → sees the Paperclip workspace with full access
|
|
280
|
+
|
|
281
|
+
### Day-to-Day Usage
|
|
282
|
+
|
|
283
|
+
- User logs into platform
|
|
284
|
+
- Platform authenticates, resolves org context
|
|
285
|
+
- User clicks into their Paperclip instance
|
|
286
|
+
- Every request carries their identity via proxy headers
|
|
287
|
+
- Paperclip shows their issues, their agents, their activity
|
|
288
|
+
- Team members see the same company but actions are attributed to the right person
|
|
289
|
+
|
|
290
|
+
### What Users Never See
|
|
291
|
+
|
|
292
|
+
- Paperclip's native invite flow
|
|
293
|
+
- Paperclip's company management
|
|
294
|
+
- Paperclip's member management
|
|
295
|
+
- Any awareness that two systems exist
|
|
296
|
+
|
|
297
|
+
## Files to Create/Modify
|
|
298
|
+
|
|
299
|
+
### paperclip (the bot)
|
|
300
|
+
|
|
301
|
+
| File | Action | Description |
|
|
302
|
+
|------|--------|-------------|
|
|
303
|
+
| `server/src/middleware/auth.ts` | Modify | Add `hosted_proxy` deployment mode branch |
|
|
304
|
+
| `server/src/routes/provision.ts` | Modify | Add addMember, removeMember, changeRole to adapter |
|
|
305
|
+
| `Dockerfile.managed` | Modify | Change PAPERCLIP_DEPLOYMENT_MODE to hosted_proxy |
|
|
306
|
+
| `scripts/upstream-sync.mjs` | Modify | Expand infraKeywords for org management patterns |
|
|
307
|
+
| `ui/src/pages/CompanySettings.tsx` | Modify | Add hostedMode guards for invite/member sections |
|
|
308
|
+
| `ui/src/components/CompanySwitcher.tsx` | Modify | Hide management links in hostedMode |
|
|
309
|
+
| `ui/src/pages/Companies.tsx` | Modify | Hide create/delete in hostedMode |
|
|
310
|
+
| `ui/src/pages/InviteLanding.tsx` | Modify | Redirect to platform in hostedMode |
|
|
311
|
+
| `ui/src/pages/BoardClaim.tsx` | Modify | Redirect to platform in hostedMode |
|
|
312
|
+
| `packages/shared/src/types.ts` | Modify | Add "hosted_proxy" to DeploymentMode union |
|
|
313
|
+
|
|
314
|
+
### paperclip-platform
|
|
315
|
+
|
|
316
|
+
| File | Action | Description |
|
|
317
|
+
|------|--------|-------------|
|
|
318
|
+
| `src/trpc/routers/org.ts` | Modify | Implement listMyOrganizations, acceptInvite, wire provisioning calls |
|
|
319
|
+
| `src/proxy/tenant-proxy.ts` | Modify | Inject + strip x-platform-user-* headers |
|
|
320
|
+
| `src/fleet/provision-client.ts` | Modify | Add addMember, removeMember, changeRole methods |
|
|
321
|
+
|
|
322
|
+
### platform-ui-core
|
|
323
|
+
|
|
324
|
+
| File | Action | Description |
|
|
325
|
+
|------|--------|-------------|
|
|
326
|
+
| Org switcher component | Create | Switch between orgs, set x-tenant-id |
|
|
327
|
+
| Invite acceptance page | Create | Accept invite → create account → join org |
|
|
328
|
+
| Member list | Modify | Populate user name/email from joined query |
|
|
329
|
+
|
|
330
|
+
### platform-core
|
|
331
|
+
|
|
332
|
+
| File | Action | Description |
|
|
333
|
+
|------|--------|-------------|
|
|
334
|
+
| `src/tenancy/org-service.ts` | Modify | Add listOrgsForUser(userId), acceptInvite(token) |
|
|
335
|
+
| provision-server package | Modify | Add member management routes to protocol |
|
|
336
|
+
|
|
337
|
+
## Dependencies
|
|
338
|
+
|
|
339
|
+
- **Blocks:** Fleet auto-update org-level config (currently tenant-level, moves to org-level after this ships)
|
|
340
|
+
- **Requires:** provision-server package update for new member management routes
|
|
341
|
+
- **Requires:** `@paperclipai/shared` types update for `hosted_proxy` deployment mode
|
|
342
|
+
|
|
343
|
+
## Risks
|
|
344
|
+
|
|
345
|
+
| Risk | Mitigation |
|
|
346
|
+
|------|------------|
|
|
347
|
+
| Upstream adds new invite/member UI patterns | Upstream sync scanner catches them via expanded keyword list |
|
|
348
|
+
| Provisioning call fails when adding member | Retry with backoff. Member shows in platform but can't access Paperclip until sync succeeds. Show "provisioning" state in UI. |
|
|
349
|
+
| Header spoofing | Strip x-platform-user-* headers from external requests before auth. Container not exposed to internet. |
|
|
350
|
+
| Paperclip upstream changes auth middleware | Our `hosted_proxy` branch is isolated — rebase conflict is localized to one function |
|
|
351
|
+
| User removed from platform but not from Paperclip | removeMember provision call is synchronous with platform removal. If it fails, retry with backoff. Future work: add periodic reconciliation job that compares platform org members with Paperclip company members and fixes drift. |
|
|
352
|
+
| Company ID mapping lost | Store Paperclip companyId in tenant record during initial provisioning. Already returned by createTenant(). |
|
|
353
|
+
|
|
354
|
+
## Future Considerations
|
|
355
|
+
|
|
356
|
+
- **SSO/SAML:** Platform handles SSO, provisions users into Paperclip on first login via SAML callback
|
|
357
|
+
- **Fine-grained permissions:** Platform roles (owner/admin/member) map to Paperclip roles. Could extend to map to Paperclip's `principalPermissionGrants` for granular control.
|
|
358
|
+
- **Multi-instance orgs:** An org could have multiple Paperclip instances (staging/prod). Provisioning calls go to all instances.
|
|
359
|
+
- **Audit trail:** Platform logs all membership changes. Paperclip logs activity via `onProvisioned` callback. Both sides have audit coverage.
|