@wopr-network/platform-core 1.17.0 → 1.18.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/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/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/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
|
@@ -19,6 +19,16 @@ describe("getTokenConfig", () => {
|
|
|
19
19
|
expect(cfg.contractAddress).toMatch(/^0x/);
|
|
20
20
|
expect(cfg.contractAddress).toBe("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913");
|
|
21
21
|
});
|
|
22
|
+
it("returns USDT on Base", () => {
|
|
23
|
+
const cfg = getTokenConfig("USDT", "base");
|
|
24
|
+
expect(cfg.decimals).toBe(6);
|
|
25
|
+
expect(cfg.contractAddress).toBe("0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2");
|
|
26
|
+
});
|
|
27
|
+
it("returns DAI on Base", () => {
|
|
28
|
+
const cfg = getTokenConfig("DAI", "base");
|
|
29
|
+
expect(cfg.decimals).toBe(18);
|
|
30
|
+
expect(cfg.contractAddress).toBe("0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb");
|
|
31
|
+
});
|
|
22
32
|
it("throws on unsupported token/chain combo", () => {
|
|
23
33
|
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
24
34
|
expect(() => getTokenConfig("USDC", "ethereum")).toThrow("Unsupported token");
|
|
@@ -14,6 +14,18 @@ const TOKENS = {
|
|
|
14
14
|
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
15
15
|
decimals: 6,
|
|
16
16
|
},
|
|
17
|
+
"USDT:base": {
|
|
18
|
+
token: "USDT",
|
|
19
|
+
chain: "base",
|
|
20
|
+
contractAddress: "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2",
|
|
21
|
+
decimals: 6,
|
|
22
|
+
},
|
|
23
|
+
"DAI:base": {
|
|
24
|
+
token: "DAI",
|
|
25
|
+
chain: "base",
|
|
26
|
+
contractAddress: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
|
|
27
|
+
decimals: 18,
|
|
28
|
+
},
|
|
17
29
|
};
|
|
18
30
|
export function getChainConfig(chain) {
|
|
19
31
|
const cfg = CHAINS[chain];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Supported EVM chains. */
|
|
2
2
|
export type EvmChain = "base";
|
|
3
3
|
/** Supported stablecoin tokens. */
|
|
4
|
-
export type StablecoinToken = "USDC";
|
|
4
|
+
export type StablecoinToken = "USDC" | "USDT" | "DAI";
|
|
5
5
|
/** Chain configuration. */
|
|
6
6
|
export interface ChainConfig {
|
|
7
7
|
readonly chain: EvmChain;
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# Fleet Auto-Update with Rolling Waves
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-03-14
|
|
4
|
+
**Status:** Draft
|
|
5
|
+
**Repos:** platform-core, paperclip, paperclip-platform, paperclip-platform-ui
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
Upstream Paperclip changes land nightly via `upstream-sync.mjs`, which rebases our fork and creates a PR. After manual review and merge, `docker-managed.yml` auto-builds and pushes `ghcr.io/wopr-network/paperclip:managed`. But existing running containers never receive the update. New containers get `:managed` on first pull; old containers are stuck on whatever digest they were created with.
|
|
10
|
+
|
|
11
|
+
platform-core has `ImagePoller` and `ContainerUpdater` classes that are fully implemented and tested but **not wired into the application lifecycle**.
|
|
12
|
+
|
|
13
|
+
## Design
|
|
14
|
+
|
|
15
|
+
### Pipeline Overview
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
paperclipai/paperclip (upstream)
|
|
19
|
+
| nightly 06:00 UTC
|
|
20
|
+
upstream-sync.mjs (rebase + hostedMode guards + changelog generation)
|
|
21
|
+
| creates PR
|
|
22
|
+
human reviews & merges PR
|
|
23
|
+
| push to master
|
|
24
|
+
docker-managed.yml (auto-build)
|
|
25
|
+
| pushes ghcr.io/wopr-network/paperclip:managed
|
|
26
|
+
ImagePoller detects new digest
|
|
27
|
+
| groups bots by tenant
|
|
28
|
+
RolloutOrchestrator executes strategy
|
|
29
|
+
| per-bot update sequence
|
|
30
|
+
ContainerUpdater (snapshot + pull + recreate + health check + rollback)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Human Gate
|
|
34
|
+
|
|
35
|
+
The only human checkpoint is **reviewing and merging the upstream sync PR**. Everything downstream is automatic.
|
|
36
|
+
|
|
37
|
+
### 1. Changelog Generation (changes to `paperclip/scripts/upstream-sync.mjs`)
|
|
38
|
+
|
|
39
|
+
After rebase and hostedMode gap scanning, the sync agent generates two changelogs:
|
|
40
|
+
|
|
41
|
+
**Internal changelog** (`changelogs/internal/YYYY-MM-DD.md`):
|
|
42
|
+
- Full developer-facing diff summary
|
|
43
|
+
- What upstream changed, what guards were added, conflicts resolved
|
|
44
|
+
- For PR review purposes
|
|
45
|
+
|
|
46
|
+
**User-facing changelog** (`changelogs/user-facing/YYYY-MM-DD.json`):
|
|
47
|
+
- Structured format: `{ version, date, sections: [{ title: "New" | "Improved" | "Fixed", items: string[] }] }`
|
|
48
|
+
- Filtered through hosted-mode exclusion list — silently drops anything related to: adapters, model selection, thinking effort, runtime/heartbeat config, provider API keys, CLI, deployment modes, infrastructure, self-hosting
|
|
49
|
+
- Same `HOSTED_MODE_CONTEXT` that drives the guard scanner drives the changelog filter
|
|
50
|
+
|
|
51
|
+
Both files are committed in the sync PR. The user-facing JSON is copied into the Docker image during build (add `COPY changelogs/user-facing/ /app/changelogs/` to `Dockerfile.managed`). If the image exists, its changelog exists.
|
|
52
|
+
|
|
53
|
+
**Changelog retrieval:** After pulling a new image (before starting the update sequence), extract the changelog:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
docker run --rm ghcr.io/wopr-network/paperclip:managed cat /app/changelogs/latest.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The extracted JSON is stored in the fleet event payload for email and UI consumption. The `latest.json` symlink always points to the most recent changelog file.
|
|
60
|
+
|
|
61
|
+
### 2. Image Detection (wire existing code in `platform-core`)
|
|
62
|
+
|
|
63
|
+
Changes to `src/fleet/services.ts`:
|
|
64
|
+
|
|
65
|
+
- Add `ImagePoller` and `ContainerUpdater` singletons
|
|
66
|
+
- `initFleet()` starts the poller and wires `poller.onUpdateAvailable` to `RolloutOrchestrator`
|
|
67
|
+
- ImagePoller already handles poll intervals per release channel (canary=5m, staging=15m, stable=30m)
|
|
68
|
+
|
|
69
|
+
### 3. Rollout Orchestrator (new: `src/fleet/rollout-orchestrator.ts`)
|
|
70
|
+
|
|
71
|
+
GoF Strategy pattern. The orchestrator is the context; strategies are interchangeable.
|
|
72
|
+
|
|
73
|
+
**`IRolloutStrategy` interface:**
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
interface IRolloutStrategy {
|
|
77
|
+
/** Select next batch from remaining bots */
|
|
78
|
+
nextBatch(remaining: BotProfile[]): BotProfile[];
|
|
79
|
+
/** Milliseconds to wait between waves */
|
|
80
|
+
pauseDuration(): number;
|
|
81
|
+
/** What to do when a single bot update fails */
|
|
82
|
+
onBotFailure(botId: string, error: Error, attempt: number): "abort" | "skip" | "retry";
|
|
83
|
+
/** Max retries per bot before skip/abort */
|
|
84
|
+
maxRetries(): number;
|
|
85
|
+
/** Health check timeout per bot (ms) */
|
|
86
|
+
healthCheckTimeout(): number;
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Concrete strategies:**
|
|
91
|
+
|
|
92
|
+
| Strategy | Batch | Pause | Failure | Use Case |
|
|
93
|
+
|----------|-------|-------|---------|----------|
|
|
94
|
+
| `RollingWaveStrategy` | configurable % | configurable | abort on N+ failures | Default for auto-update |
|
|
95
|
+
| `SingleBotStrategy` | 1 bot | N/A | report | Manual per-bot update button |
|
|
96
|
+
| `ImmediateStrategy` | all | 0 | skip | Emergency hotfix |
|
|
97
|
+
|
|
98
|
+
Strategy selection is **admin-controlled only** — users never see this.
|
|
99
|
+
|
|
100
|
+
**Orchestrator flow:**
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
1. Group update-eligible bots by tenant
|
|
104
|
+
2. For each tenant:
|
|
105
|
+
a. Check tenant update mode (auto/manual)
|
|
106
|
+
b. If manual: mark bots as "update available", send notification, stop
|
|
107
|
+
c. If auto: check if current time is within tenant's preferred window
|
|
108
|
+
d. Select strategy (from admin config)
|
|
109
|
+
e. Execute waves:
|
|
110
|
+
- batch = strategy.nextBatch(remaining)
|
|
111
|
+
- for each bot in batch: ContainerUpdater.updateBot()
|
|
112
|
+
- if any failure: strategy.onBotFailure() → abort/skip/retry
|
|
113
|
+
- sleep(strategy.pauseDuration())
|
|
114
|
+
- repeat until remaining is empty
|
|
115
|
+
3. Send notification emails with changelog
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 4. Update Sequence Per Bot (major rework of `ContainerUpdater`)
|
|
119
|
+
|
|
120
|
+
Nuclear rollback — image AND volumes roll back together.
|
|
121
|
+
|
|
122
|
+
**Volume Snapshot Mechanism (new: `VolumeSnapshotManager`):**
|
|
123
|
+
|
|
124
|
+
The existing `SnapshotManager` operates on filesystem paths, not Docker named volumes. A new `VolumeSnapshotManager` is needed that snapshots Docker named volumes using a temporary container:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Snapshot a named volume to a tar archive:
|
|
128
|
+
docker run --rm -v <volume-name>:/source -v <backup-dir>:/backup alpine \
|
|
129
|
+
tar cf /backup/<volume-name>-<timestamp>.tar -C /source .
|
|
130
|
+
|
|
131
|
+
# Restore a named volume from a tar archive:
|
|
132
|
+
docker run --rm -v <volume-name>:/target -v <backup-dir>:/backup alpine \
|
|
133
|
+
sh -c "rm -rf /target/* && tar xf /backup/<volume-name>-<timestamp>.tar -C /target"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This is a new class (`src/fleet/volume-snapshot-manager.ts`), not a modification of the existing `SnapshotManager`.
|
|
137
|
+
|
|
138
|
+
**Update sequence:**
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
1. Snapshot /data and /paperclip volumes (via VolumeSnapshotManager)
|
|
142
|
+
2. Record previous image digest (already implemented)
|
|
143
|
+
3. Pull new image
|
|
144
|
+
4. Stop container
|
|
145
|
+
5. Recreate container with new image (named volumes remount automatically)
|
|
146
|
+
6. Start container (PAPERCLIP_MIGRATION_AUTO_APPLY=true runs Drizzle migrations on boot)
|
|
147
|
+
7. Health check: HTTP GET http://container:3100/health, expect {"status":"ok"}
|
|
148
|
+
- Timeout: 120s (increased from current 60s to allow for Drizzle migration time)
|
|
149
|
+
- Poll interval: 5s
|
|
150
|
+
8a. HEALTHY:
|
|
151
|
+
- Delete volume snapshots
|
|
152
|
+
- Emit fleet event: bot.updated
|
|
153
|
+
- Record new digest
|
|
154
|
+
8b. UNHEALTHY:
|
|
155
|
+
- Stop container
|
|
156
|
+
- Restore volume snapshots from step 1 (via VolumeSnapshotManager)
|
|
157
|
+
- Recreate container with OLD image (digest-pinned to prevent re-pulling new)
|
|
158
|
+
- Start container
|
|
159
|
+
- Verify old container is healthy
|
|
160
|
+
- Emit fleet event: bot.update_failed
|
|
161
|
+
- Report to orchestrator (abort/skip/retry per strategy)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Health check upgrade:** Replace `node -e 'process.exit(0)'` in `createContainer()` with:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
Healthcheck: {
|
|
168
|
+
// Use node+fetch instead of curl — Paperclip's base image (node:lts-trixie-slim)
|
|
169
|
+
// may not have curl installed.
|
|
170
|
+
Test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3100/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""],
|
|
171
|
+
Interval: 30_000_000_000,
|
|
172
|
+
Timeout: 10_000_000_000,
|
|
173
|
+
Retries: 3,
|
|
174
|
+
StartPeriod: 60_000_000_000, // 60s for Drizzle migrations on boot
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Note:** `HEALTH_CHECK_TIMEOUT_MS` in `ContainerUpdater` must be increased from 60,000 to 120,000 to match the spec's 120s timeout.
|
|
179
|
+
|
|
180
|
+
### 5. Tenant Update Config
|
|
181
|
+
|
|
182
|
+
Stored per-tenant (moves to per-org when org support ships — see `2026-03-14-paperclip-org-integration-design.md`).
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
interface TenantUpdateConfig {
|
|
186
|
+
/** "auto" = rolling wave in preferred window; "manual" = badge + button */
|
|
187
|
+
mode: "auto" | "manual";
|
|
188
|
+
/** Hour of day (UTC) for auto-update window. Only used when mode=auto. */
|
|
189
|
+
preferredHourUtc: number; // 0-23, default 3
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Default for new tenants: `{ mode: "manual", preferredHourUtc: 3 }`.
|
|
194
|
+
|
|
195
|
+
**Repository interface** (follows the `IFooRepository` pattern used throughout platform-core):
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
export interface ITenantUpdateConfigRepository {
|
|
199
|
+
get(tenantId: string): Promise<TenantUpdateConfig | null>;
|
|
200
|
+
upsert(tenantId: string, config: TenantUpdateConfig): Promise<void>;
|
|
201
|
+
listAutoEnabled(): Promise<Array<{ tenantId: string; config: TenantUpdateConfig }>>;
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`DrizzleTenantUpdateConfigRepository` implements this against a `tenant_update_configs` table with columns `(tenant_id TEXT PK, mode TEXT, preferred_hour_utc INTEGER, updated_at BIGINT)`.
|
|
206
|
+
|
|
207
|
+
**Audit logging:** All config changes (mode switch, hour change) are logged via `logger.info("Tenant update config changed", { tenantId, oldConfig, newConfig, actorUserId })`. Admin-triggered updates via the `/admin/updates` route include the actor in the log entry.
|
|
208
|
+
|
|
209
|
+
Admin panel can override per-tenant or set global defaults.
|
|
210
|
+
|
|
211
|
+
**Precedence: tenant config overrides per-bot `updatePolicy`.** The existing `BotProfile.updatePolicy` field (per-bot: `on-push`, `nightly`, `manual`, `cron:*`) is superseded by `TenantUpdateConfig` for hosted deployments. The `RolloutOrchestrator` reads tenant config, not bot-level policy. `ImagePoller.shouldAutoUpdate()` is refactored to always return `false` — the poller's only job is to detect new digests and notify the orchestrator, which makes the auto/manual decision based on tenant config.
|
|
212
|
+
|
|
213
|
+
`ImagePoller.isNightlyWindow()` (hardcoded 03:00-03:30 UTC) is superseded by the orchestrator's per-tenant `preferredHourUtc` window check. The poller's nightly logic becomes a no-op.
|
|
214
|
+
|
|
215
|
+
Per-bot `updatePolicy` is preserved in the schema for self-hosted (non-platform) deployments where there is no tenant config.
|
|
216
|
+
|
|
217
|
+
### 6. Admin Controls
|
|
218
|
+
|
|
219
|
+
Admin panel (platform-core admin routes, not user-facing):
|
|
220
|
+
|
|
221
|
+
- **Global update mode**: auto / manual / paused (pause halts all rollouts fleet-wide)
|
|
222
|
+
- **Strategy config**: batch %, pause duration, failure threshold
|
|
223
|
+
- **Default update window**: hour UTC
|
|
224
|
+
- **Per-tenant overrides**: mode, window
|
|
225
|
+
- **Manual triggers**: "roll out now" for a specific image digest
|
|
226
|
+
- **Rollout status dashboard**: which bots updated, which failed, which pending
|
|
227
|
+
|
|
228
|
+
### 7. User-Facing Experience
|
|
229
|
+
|
|
230
|
+
**Auto mode (tenant doesn't know or care):**
|
|
231
|
+
- Updates happen silently during configured window
|
|
232
|
+
- Email after: "Your Paperclip was updated. Here's what's new: [changelog]"
|
|
233
|
+
- Brief downtime during container restart (seconds)
|
|
234
|
+
|
|
235
|
+
**Manual mode:**
|
|
236
|
+
- Email when update available: "A new update is available for your Paperclip. [changelog]"
|
|
237
|
+
- In-app: badge on bot in UI indicating update available
|
|
238
|
+
- Click "Update" → modal shows user-facing changelog with "Update Now" / "Later" buttons
|
|
239
|
+
- "Update Now" triggers `SingleBotStrategy` immediately
|
|
240
|
+
- Email after: "Your Paperclip was updated. Here's what's new: [changelog]"
|
|
241
|
+
|
|
242
|
+
**Both modes:**
|
|
243
|
+
- Admin email on rollback failure
|
|
244
|
+
- Fleet event log for audit
|
|
245
|
+
|
|
246
|
+
### 8. Image Allowlist
|
|
247
|
+
|
|
248
|
+
`FLEET_IMAGE_ALLOWLIST` already allows `ghcr.io/wopr-network/` — covers both WOPR and Paperclip images. Future brands add their prefix.
|
|
249
|
+
|
|
250
|
+
## Files to Create/Modify
|
|
251
|
+
|
|
252
|
+
### platform-core
|
|
253
|
+
|
|
254
|
+
| File | Action | Description |
|
|
255
|
+
|------|--------|-------------|
|
|
256
|
+
| `src/fleet/rollout-orchestrator.ts` | Create | Strategy pattern orchestrator |
|
|
257
|
+
| `src/fleet/rollout-strategies.ts` | Create | RollingWave, SingleBot, Immediate strategies |
|
|
258
|
+
| `src/fleet/services.ts` | Modify | Wire ImagePoller + ContainerUpdater + RolloutOrchestrator into initFleet() |
|
|
259
|
+
| `src/fleet/updater.ts` | Major rework | Add volume snapshot/restore lifecycle, replace FleetManager delegation with direct Docker operations for atomic update, upgrade health check from Docker HEALTHCHECK polling to HTTP GET, increase timeout from 60s to 120s |
|
|
260
|
+
| `src/fleet/volume-snapshot-manager.ts` | Create | Snapshot and restore Docker named volumes using temporary alpine containers |
|
|
261
|
+
| `src/fleet/fleet-manager.ts` | Modify | Upgrade HEALTHCHECK in createContainer() to use node+fetch instead of node -e |
|
|
262
|
+
| `src/fleet/image-poller.ts` | Modify | Wire onUpdateAvailable to orchestrator instead of direct updater |
|
|
263
|
+
| `src/db/schema/tenant-update-config.ts` | Create | Drizzle schema for tenant update preferences |
|
|
264
|
+
| `src/api/routes/admin-updates.ts` | Create | Admin API for update management |
|
|
265
|
+
| `src/fleet/update-notifier.ts` | Create | Email notifications for updates |
|
|
266
|
+
|
|
267
|
+
### paperclip
|
|
268
|
+
|
|
269
|
+
| File | Action | Description |
|
|
270
|
+
|------|--------|-------------|
|
|
271
|
+
| `scripts/upstream-sync.mjs` | Modify | Add changelog generation step |
|
|
272
|
+
| `Dockerfile.managed` | Modify | COPY changelogs into image |
|
|
273
|
+
| `changelogs/` | Create | Directory for generated changelogs |
|
|
274
|
+
|
|
275
|
+
### paperclip-platform-ui
|
|
276
|
+
|
|
277
|
+
| File | Action | Description |
|
|
278
|
+
|------|--------|-------------|
|
|
279
|
+
| Update modal component | Create | Shows changelog, "Update Now" / "Later" |
|
|
280
|
+
| Bot card badge | Modify | Show "Update Available" indicator |
|
|
281
|
+
|
|
282
|
+
## Dependencies
|
|
283
|
+
|
|
284
|
+
- **Implementation work required:**
|
|
285
|
+
- `ImagePoller` and `ContainerUpdater` classes exist and are tested, but have no singleton getters in `services.ts` and are not imported or wired. Docker instance injection needs to be plumbed through.
|
|
286
|
+
- `ContainerUpdater` needs significant enhancement: volume snapshot/restore integration with `SnapshotManager`, HTTP-based health checks (replacing `node -e`), increased timeout from 60s to 120s for migration time.
|
|
287
|
+
- `RolloutOrchestrator` and strategies are entirely new code.
|
|
288
|
+
- `SnapshotManager` exists in `src/backup/` but has no integration with `ContainerUpdater`.
|
|
289
|
+
- **Future:** Org support (see `2026-03-14-paperclip-org-integration-design.md`) — update config moves from tenant to org level after org integration ships
|
|
290
|
+
- **Future:** Cron policy implementation in ImagePoller (currently stubbed)
|
|
291
|
+
|
|
292
|
+
## Risks
|
|
293
|
+
|
|
294
|
+
| Risk | Mitigation |
|
|
295
|
+
|------|------------|
|
|
296
|
+
| Bad upstream migration corrupts data | Nuclear rollback: volume snapshot restored alongside image rollback |
|
|
297
|
+
| Upstream pushes breaking change | Human gate at sync PR review catches this before any image is built |
|
|
298
|
+
| Rolling wave takes too long | ImmediateStrategy available for emergency hotfixes |
|
|
299
|
+
| Health check passes but app is subtly broken | `/health` endpoint queries DB, so migration failures surface. Consider adding deeper health checks later. |
|
|
300
|
+
| Volume snapshots consume disk | Snapshots deleted after successful update. Failed rollbacks alert admin for manual cleanup. |
|
|
@@ -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.
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# Role Management & Permissions
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-03-14
|
|
4
|
+
**Status:** Draft
|
|
5
|
+
**Repos:** platform-core, platform-ui-core, paperclip, paperclip-platform, paperclip-platform-ui
|
|
6
|
+
**Depends on:** `2026-03-14-paperclip-org-integration-design.md` (ships together or in strict sequence — this spec amends the org integration spec's `addMember`/`changeRole` to include permission grant provisioning)
|
|
7
|
+
|
|
8
|
+
## Problem
|
|
9
|
+
|
|
10
|
+
The platform has three org roles (owner/admin/member) that gate team management operations. But billing, fleet operations, update preferences, and Paperclip-internal permissions have no role gating. A member can see billing info, restart bots, create agents, and view costs — all of which should be admin-only.
|
|
11
|
+
|
|
12
|
+
The platform controls the whole stack. Role enforcement happens at the right level: platform gates platform features, Paperclip gates Paperclip features. No workarounds.
|
|
13
|
+
|
|
14
|
+
## Design
|
|
15
|
+
|
|
16
|
+
### Permission Model
|
|
17
|
+
|
|
18
|
+
Three fixed roles. No per-user customization. Simple, predictable, covers the product.
|
|
19
|
+
|
|
20
|
+
**Owner** — full control. One per org.
|
|
21
|
+
**Admin** — manages team, billing, fleet, agents. Cannot delete org or transfer ownership.
|
|
22
|
+
**Member** — uses the product. Issues, projects, org chart. Nothing else.
|
|
23
|
+
|
|
24
|
+
### Permission Matrix
|
|
25
|
+
|
|
26
|
+
| Capability | Owner | Admin | Member |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| **Team** | | | |
|
|
29
|
+
| Invite members | Y | Y | N |
|
|
30
|
+
| Remove members | Y | Y | N |
|
|
31
|
+
| Change roles | Y | Y | N |
|
|
32
|
+
| Transfer ownership | Y | N | N |
|
|
33
|
+
| Delete org | Y | N | N |
|
|
34
|
+
| **Billing** | | | |
|
|
35
|
+
| View balance/usage/invoices | Y | Y | N |
|
|
36
|
+
| Top up credits | Y | Y | N |
|
|
37
|
+
| **Fleet** | | | |
|
|
38
|
+
| Start/stop/restart bot | Y | Y | N |
|
|
39
|
+
| Trigger manual update | Y | Y | N |
|
|
40
|
+
| Set update mode (auto/manual) | Y | Y | N |
|
|
41
|
+
| View logs | Y | Y | Y |
|
|
42
|
+
| View bot status/health | Y | Y | Y |
|
|
43
|
+
| View changelog | Y | Y | Y |
|
|
44
|
+
| **Inside Paperclip** | | | |
|
|
45
|
+
| Create/manage agents | Y | Y | N |
|
|
46
|
+
| View costs | Y | Y | N |
|
|
47
|
+
| Delete company data | Y | N | N |
|
|
48
|
+
| Create/edit issues | Y | Y | Y |
|
|
49
|
+
| Create/edit projects | Y | Y | Y |
|
|
50
|
+
| View org chart | Y | Y | Y |
|
|
51
|
+
|
|
52
|
+
### Where Enforcement Happens
|
|
53
|
+
|
|
54
|
+
Permissions are enforced at the correct layer — no double-gating, no workarounds.
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Platform (tRPC routes)
|
|
58
|
+
├─ Team management → already gated by OrgService (owner/admin check on line 310)
|
|
59
|
+
├─ Billing routes → gate behind admin/owner role check
|
|
60
|
+
├─ Fleet routes → gate start/stop/restart/update behind admin/owner
|
|
61
|
+
└─ Fleet read routes → allow all members (logs, status, health, changelog)
|
|
62
|
+
|
|
63
|
+
Platform UI (platform-ui-core)
|
|
64
|
+
├─ Settings tabs → hide billing, fleet controls, team tabs for members
|
|
65
|
+
├─ Update button → hide for members (admin/owner only)
|
|
66
|
+
└─ Bot controls → hide start/stop/restart for members
|
|
67
|
+
|
|
68
|
+
Paperclip (provisioned permissions)
|
|
69
|
+
├─ Agent management → admin/owner get permission grants; members don't
|
|
70
|
+
├─ Cost visibility → admin/owner get cost view grants; members don't
|
|
71
|
+
├─ Company deletion → owner only gets delete grant
|
|
72
|
+
└─ Issues/projects → all roles get full issue/project permissions
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 1. Platform-Side Role Gating (tRPC)
|
|
76
|
+
|
|
77
|
+
**File:** `paperclip-platform/src/trpc/routers/fleet.ts`
|
|
78
|
+
|
|
79
|
+
Fleet mutation routes (start, stop, restart, destroy, update) need admin/owner checks.
|
|
80
|
+
|
|
81
|
+
**Prerequisite:** Today's `orgMemberProcedure` only checks membership existence — it does NOT check role or expose `member.role` to the context. A new `orgAdminProcedure` middleware is needed in platform-core:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// platform-core: new middleware
|
|
85
|
+
const orgAdminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
|
86
|
+
const member = await orgMemberRepo.findMember(ctx.tenantId, ctx.user.id);
|
|
87
|
+
if (!member || member.role === "member") {
|
|
88
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Admin or owner role required" });
|
|
89
|
+
}
|
|
90
|
+
return next({ ctx: { ...ctx, orgRole: member.role } });
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Also extend `orgMemberProcedure` to attach `ctx.orgRole` so downstream code can read the role without re-querying.
|
|
95
|
+
|
|
96
|
+
Fleet mutation routes switch from `protectedProcedure` to `orgAdminProcedure`. Fleet read routes (listInstances, getInstance, getInstanceHealth, getInstanceLogs) use `orgMemberProcedure` — accessible to all members.
|
|
97
|
+
|
|
98
|
+
**File:** `paperclip-platform/src/trpc/routers/org.ts`
|
|
99
|
+
|
|
100
|
+
Billing routes need admin/owner checks. The existing `requireAdminOrOwner` pattern at line 310 of `platform-core/src/tenancy/org-service.ts` should be extracted into a reusable helper:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// platform-core/src/tenancy/org-service.ts
|
|
104
|
+
async requireAdminOrOwner(orgId: string, userId: string): Promise<void> {
|
|
105
|
+
const member = await this.memberRepo.findMember(orgId, userId);
|
|
106
|
+
if (!member || (member.role !== "admin" && member.role !== "owner")) {
|
|
107
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Admin or owner role required" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async requireOwner(orgId: string, userId: string): Promise<void> {
|
|
112
|
+
const org = await this.orgRepo.getOrg(orgId);
|
|
113
|
+
if (!org || org.ownerId !== userId) {
|
|
114
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Owner role required" });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Apply to routes:
|
|
120
|
+
|
|
121
|
+
| Route | Gate |
|
|
122
|
+
|-------|------|
|
|
123
|
+
| `orgBillingBalance` | `requireAdminOrOwner` |
|
|
124
|
+
| `orgBillingInfo` | `requireAdminOrOwner` |
|
|
125
|
+
| `orgMemberUsage` | `requireAdminOrOwner` |
|
|
126
|
+
| `orgTopupCheckout` | `requireAdminOrOwner` |
|
|
127
|
+
| `orgSetupIntent` | `requireAdminOrOwner` |
|
|
128
|
+
| `inviteMember` | `requireAdminOrOwner` (already gated) |
|
|
129
|
+
| `removeMember` | `requireAdminOrOwner` (already gated) |
|
|
130
|
+
| `changeRole` | `requireAdminOrOwner` (already gated) |
|
|
131
|
+
| `deleteOrganization` | `requireOwner` (already gated) |
|
|
132
|
+
| `transferOwnership` | `requireOwner` (already gated) |
|
|
133
|
+
|
|
134
|
+
### 2. Platform UI Role Gating
|
|
135
|
+
|
|
136
|
+
**File:** `platform-ui-core/src/app/(dashboard)/settings/`
|
|
137
|
+
|
|
138
|
+
The settings page needs to conditionally render tabs based on the user's role. The user's role is available from the org context (tRPC `getOrganization` response includes the member list with roles).
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// Hook: useMyOrgRole()
|
|
142
|
+
// Returns "owner" | "admin" | "member" | null
|
|
143
|
+
// Derived from org.members.find(m => m.userId === currentUser.id)?.role
|
|
144
|
+
|
|
145
|
+
const role = useMyOrgRole();
|
|
146
|
+
const isAdminOrOwner = role === "admin" || role === "owner";
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Tab visibility:**
|
|
150
|
+
|
|
151
|
+
| Tab | Visible to |
|
|
152
|
+
|-----|-----------|
|
|
153
|
+
| Team / Members | admin, owner |
|
|
154
|
+
| Billing | admin, owner |
|
|
155
|
+
| Bot Management (start/stop/restart) | admin, owner |
|
|
156
|
+
| Update Preferences | admin, owner |
|
|
157
|
+
| Bot Status / Logs | all |
|
|
158
|
+
| General Settings (name, etc.) | admin, owner |
|
|
159
|
+
|
|
160
|
+
Members see a simplified dashboard: bot status, logs, changelog. No controls, no billing, no team management.
|
|
161
|
+
|
|
162
|
+
**Update modal:**
|
|
163
|
+
|
|
164
|
+
The "Update Available" badge and changelog are visible to all roles. The "Update Now" button is only visible to admin/owner. Members see the changelog but can't trigger the update.
|
|
165
|
+
|
|
166
|
+
### 3. Paperclip-Side Permission Provisioning
|
|
167
|
+
|
|
168
|
+
**File:** `paperclip/server/src/routes/provision.ts`
|
|
169
|
+
|
|
170
|
+
When the platform provisions a user into Paperclip (via `addMember`), it also sets their permissions based on their platform role. This uses Paperclip's existing `principalPermissionGrants` system.
|
|
171
|
+
|
|
172
|
+
**Permission grants by role:**
|
|
173
|
+
|
|
174
|
+
Paperclip uses colon-delimited permission keys defined in `@paperclipai/shared/constants.ts`. The existing keys are: `agents:create`, `users:invite`, `users:manage_permissions`, `tasks:assign`, `tasks:assign_scope`, `joins:approve`.
|
|
175
|
+
|
|
176
|
+
**Prerequisite:** Several permission keys needed for role-based gating do not exist yet. These must be added to `PERMISSION_KEYS` in `paperclip/packages/shared/src/constants.ts` before implementation:
|
|
177
|
+
|
|
178
|
+
| New Key | Purpose |
|
|
179
|
+
|---------|---------|
|
|
180
|
+
| `agents:update` | Edit agent config |
|
|
181
|
+
| `agents:delete` | Remove agents |
|
|
182
|
+
| `costs:view` | See budget/spending |
|
|
183
|
+
| `company:delete` | Delete entire company |
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
import type { PermissionKey } from "@paperclipai/shared";
|
|
187
|
+
|
|
188
|
+
type GrantInput = { permissionKey: PermissionKey; scope?: Record<string, unknown> | null };
|
|
189
|
+
|
|
190
|
+
const ROLE_PERMISSIONS: Record<string, GrantInput[]> = {
|
|
191
|
+
owner: [
|
|
192
|
+
{ permissionKey: "agents:create" },
|
|
193
|
+
{ permissionKey: "agents:update" },
|
|
194
|
+
{ permissionKey: "agents:delete" },
|
|
195
|
+
{ permissionKey: "costs:view" },
|
|
196
|
+
{ permissionKey: "company:delete" },
|
|
197
|
+
{ permissionKey: "users:invite" },
|
|
198
|
+
{ permissionKey: "users:manage_permissions" },
|
|
199
|
+
{ permissionKey: "tasks:assign" },
|
|
200
|
+
{ permissionKey: "joins:approve" },
|
|
201
|
+
],
|
|
202
|
+
admin: [
|
|
203
|
+
{ permissionKey: "agents:create" },
|
|
204
|
+
{ permissionKey: "agents:update" },
|
|
205
|
+
{ permissionKey: "agents:delete" },
|
|
206
|
+
{ permissionKey: "costs:view" },
|
|
207
|
+
{ permissionKey: "users:invite" },
|
|
208
|
+
{ permissionKey: "users:manage_permissions" },
|
|
209
|
+
{ permissionKey: "tasks:assign" },
|
|
210
|
+
{ permissionKey: "joins:approve" },
|
|
211
|
+
],
|
|
212
|
+
member: [
|
|
213
|
+
{ permissionKey: "tasks:assign" },
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**Note:** Issues and projects have no permission gates in Paperclip today — all company members can create/edit them. The org chart is also ungated — all authenticated users see it. No new keys needed for these.
|
|
219
|
+
|
|
220
|
+
This spec **amends** the org integration spec's `addMember` and `changeRole` provision endpoints to include permission grant provisioning. Both changes ship together.
|
|
221
|
+
|
|
222
|
+
The `addMember` provision endpoint sets these grants:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
async addMember(companyId: string, user: AdminUser, role: "owner" | "admin" | "member") {
|
|
226
|
+
await this.ensureUser(user);
|
|
227
|
+
const paperclipRole = role === "member" ? "member" : "owner";
|
|
228
|
+
await access.ensureMembership(companyId, "user", user.id, paperclipRole, "active");
|
|
229
|
+
if (paperclipRole === "owner") {
|
|
230
|
+
await access.promoteInstanceAdmin(user.id);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Set permission grants based on platform role
|
|
234
|
+
const grants = ROLE_PERMISSIONS[role] ?? ROLE_PERMISSIONS.member;
|
|
235
|
+
await access.setPrincipalGrants(companyId, "user", user.id, grants, "platform");
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The `changeRole` endpoint updates grants when a role changes:
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
async changeRole(companyId: string, userId: string, role: "owner" | "admin" | "member") {
|
|
243
|
+
// ... existing role/membership logic from org integration spec ...
|
|
244
|
+
|
|
245
|
+
// Update permission grants to match new role
|
|
246
|
+
const grants = ROLE_PERMISSIONS[role] ?? ROLE_PERMISSIONS.member;
|
|
247
|
+
await access.setPrincipalGrants(companyId, "user", userId, grants, "platform");
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### 4. Paperclip UI Enforcement (hostedMode)
|
|
252
|
+
|
|
253
|
+
Paperclip's UI already has a permission system, but some controls need hostedMode-specific hiding. In hosted mode, the platform is the authority — Paperclip should not show controls that the platform's role doesn't allow.
|
|
254
|
+
|
|
255
|
+
**Agent management controls:**
|
|
256
|
+
Already gated by the `agent.create` permission grant. Members without the grant won't see create/edit/delete agent buttons. No additional hostedMode guard needed — the permission system handles it.
|
|
257
|
+
|
|
258
|
+
**Cost page:**
|
|
259
|
+
Gate behind `costs:view` permission. Members without it see a "Contact your admin" message or the page is hidden from navigation entirely.
|
|
260
|
+
|
|
261
|
+
**Company deletion:**
|
|
262
|
+
Already behind instance admin check. Members are never instance admin. No change needed.
|
|
263
|
+
|
|
264
|
+
**Key principle:** In hosted mode, Paperclip's permission grants are the enforcement mechanism. The platform sets the right grants during provisioning. Paperclip's UI respects those grants. No additional hostedMode guards needed for role-based features — only for org management UI (invites, members, company settings) which is hidden entirely per the org integration spec.
|
|
265
|
+
|
|
266
|
+
### 5. Role Change Propagation
|
|
267
|
+
|
|
268
|
+
When a role changes on the platform, the change must propagate to Paperclip:
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
Platform: admin changes Alice from member to admin
|
|
272
|
+
→ platform updates organization_members role
|
|
273
|
+
→ platform calls POST instance:3100/internal/members/change-role
|
|
274
|
+
{ companyId, userId: alice.id, role: "admin" }
|
|
275
|
+
→ Paperclip updates membership + permission grants
|
|
276
|
+
→ Alice's next request gets the expanded permissions
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
No restart needed. No container recreation. Permission grants take effect immediately on the next request because `actorMiddleware` resolves memberships fresh on each request.
|
|
280
|
+
|
|
281
|
+
## Files to Create/Modify
|
|
282
|
+
|
|
283
|
+
### platform-core
|
|
284
|
+
|
|
285
|
+
| File | Action | Description |
|
|
286
|
+
|------|--------|-------------|
|
|
287
|
+
| `src/tenancy/org-service.ts` | Modify | Extract `requireAdminOrOwner()` and `requireOwner()` helpers |
|
|
288
|
+
| `src/tenancy/org-member-procedure.ts` | Create | `orgAdminProcedure` middleware that checks admin/owner role; extend `orgMemberProcedure` to attach `ctx.orgRole` |
|
|
289
|
+
|
|
290
|
+
### paperclip-platform
|
|
291
|
+
|
|
292
|
+
| File | Action | Description |
|
|
293
|
+
|------|--------|-------------|
|
|
294
|
+
| `src/trpc/routers/fleet.ts` | Modify | Add admin/owner role checks to mutation routes |
|
|
295
|
+
| `src/trpc/routers/org.ts` | Modify | Add admin/owner role checks to billing routes |
|
|
296
|
+
|
|
297
|
+
### platform-ui-core
|
|
298
|
+
|
|
299
|
+
| File | Action | Description |
|
|
300
|
+
|------|--------|-------------|
|
|
301
|
+
| `src/hooks/useMyOrgRole.ts` | Create | Hook to get current user's org role |
|
|
302
|
+
| `src/app/(dashboard)/settings/` | Modify | Conditionally render tabs based on role |
|
|
303
|
+
| Bot management components | Modify | Hide start/stop/restart controls for members |
|
|
304
|
+
| Update modal | Modify | Hide "Update Now" button for members |
|
|
305
|
+
|
|
306
|
+
### paperclip
|
|
307
|
+
|
|
308
|
+
| File | Action | Description |
|
|
309
|
+
|------|--------|-------------|
|
|
310
|
+
| `server/src/routes/provision.ts` | Modify | Set permission grants based on platform role in addMember/changeRole |
|
|
311
|
+
| `packages/shared/src/constants.ts` | Modify | Add new PERMISSION_KEYS: `agents:update`, `agents:delete`, `costs:view`, `company:delete` |
|
|
312
|
+
|
|
313
|
+
### paperclip-platform-ui
|
|
314
|
+
|
|
315
|
+
| File | Action | Description |
|
|
316
|
+
|------|--------|-------------|
|
|
317
|
+
| Settings layout | Modify | Hide billing/team/fleet tabs for members |
|
|
318
|
+
| Bot card | Modify | Hide controls for members, keep status/logs visible |
|
|
319
|
+
|
|
320
|
+
## Edge Cases
|
|
321
|
+
|
|
322
|
+
| Scenario | Behavior |
|
|
323
|
+
|----------|----------|
|
|
324
|
+
| Owner demoted to member | Not possible — owner must transfer ownership first |
|
|
325
|
+
| Last admin removed | OrgService already prevents this (countAdminsAndOwners check) |
|
|
326
|
+
| Admin changes own role to member | Allowed — they lose admin access immediately |
|
|
327
|
+
| User in multiple orgs with different roles | Roles are per-org. Switching org changes visible permissions |
|
|
328
|
+
| Provision call fails during role change | Platform role is updated. Paperclip permissions are stale until retry succeeds. User may have expanded/restricted access temporarily. Reconciliation job (from org spec) catches drift. |
|
|
329
|
+
| New permission added in future | Add to ROLE_PERMISSIONS map. Existing users get it on next role change or via reconciliation. |
|
|
330
|
+
|
|
331
|
+
## Risks
|
|
332
|
+
|
|
333
|
+
| Risk | Mitigation |
|
|
334
|
+
|------|------------|
|
|
335
|
+
| Platform and Paperclip permissions drift | Provision calls are synchronous with role changes. Reconciliation job as safety net. |
|
|
336
|
+
| Upstream Paperclip adds new permission-gated features | Members won't have grants for new permissions by default — safe fail-closed. Admins get grants added via ROLE_PERMISSIONS update. |
|
|
337
|
+
| Role check latency on every tRPC call | org member lookup is a single indexed query. Cache role in session if needed. |
|
|
338
|
+
| Member sees flash of admin UI before role loads | `useMyOrgRole()` returns null while loading. Show skeleton/nothing until role resolves. |
|
|
339
|
+
|
|
340
|
+
## Future Considerations
|
|
341
|
+
|
|
342
|
+
- **Viewer role:** If a read-only role is needed (common enterprise request), add `"viewer"` to the role enum with an empty `ROLE_PERMISSIONS` entry. Viewers would see status/logs/changelog but cannot create issues or projects. The three-role model is extensible to four without architectural changes.
|
|
343
|
+
- **Custom permission sets:** If customers need "billing admin" or "agent manager" sub-roles, extend ROLE_PERMISSIONS with custom role definitions. The infrastructure supports it — just add roles.
|
|
344
|
+
- **Org-level feature flags:** Some features could be gated per org tier (enterprise gets more). Orthogonal to roles but shares the same gating infrastructure.
|
|
345
|
+
- **Audit log:** Log all role changes and permission grant modifications for compliance.
|
|
346
|
+
- **Reconciliation job:** Periodic job (e.g., hourly cron) that compares platform org members + roles against Paperclip company members + permission grants, and corrects any drift from failed provision calls. Defined in the org integration spec as future work — should be implemented alongside or shortly after initial ship.
|
package/package.json
CHANGED
|
@@ -23,6 +23,18 @@ describe("getTokenConfig", () => {
|
|
|
23
23
|
expect(cfg.contractAddress).toBe("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
it("returns USDT on Base", () => {
|
|
27
|
+
const cfg = getTokenConfig("USDT", "base");
|
|
28
|
+
expect(cfg.decimals).toBe(6);
|
|
29
|
+
expect(cfg.contractAddress).toBe("0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns DAI on Base", () => {
|
|
33
|
+
const cfg = getTokenConfig("DAI", "base");
|
|
34
|
+
expect(cfg.decimals).toBe(18);
|
|
35
|
+
expect(cfg.contractAddress).toBe("0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb");
|
|
36
|
+
});
|
|
37
|
+
|
|
26
38
|
it("throws on unsupported token/chain combo", () => {
|
|
27
39
|
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
28
40
|
expect(() => getTokenConfig("USDC" as any, "ethereum" as any)).toThrow("Unsupported token");
|
|
@@ -10,13 +10,25 @@ const CHAINS: Record<EvmChain, ChainConfig> = {
|
|
|
10
10
|
},
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
const TOKENS: Record<`${StablecoinToken}:${EvmChain}`, TokenConfig
|
|
13
|
+
const TOKENS: Partial<Record<`${StablecoinToken}:${EvmChain}`, TokenConfig>> = {
|
|
14
14
|
"USDC:base": {
|
|
15
15
|
token: "USDC",
|
|
16
16
|
chain: "base",
|
|
17
17
|
contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
18
18
|
decimals: 6,
|
|
19
19
|
},
|
|
20
|
+
"USDT:base": {
|
|
21
|
+
token: "USDT",
|
|
22
|
+
chain: "base",
|
|
23
|
+
contractAddress: "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2",
|
|
24
|
+
decimals: 6,
|
|
25
|
+
},
|
|
26
|
+
"DAI:base": {
|
|
27
|
+
token: "DAI",
|
|
28
|
+
chain: "base",
|
|
29
|
+
contractAddress: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
|
|
30
|
+
decimals: 18,
|
|
31
|
+
},
|
|
20
32
|
};
|
|
21
33
|
|
|
22
34
|
export function getChainConfig(chain: EvmChain): ChainConfig {
|