Files
claudemesh/docs/member-profile-spec.md
Alejandro Gutiérrez 0661e6223a
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
fix(web): correct LinkedIn URL on about page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:17:24 +01:00

664 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Member Profile: Persistent Identity & Dashboard Management
> Spec for moving member identity (role tag, groups, display name, message
> mode) from ephemeral CLI flags to persistent server-side state, editable
> from the dashboard with configurable self-edit permissions.
---
## Problem
Today, launching a claudemesh session requires re-declaring your identity:
```bash
claudemesh launch --name Alice --role lead --groups eng,review --message-mode push
```
Every. Single. Time. These values live on the ephemeral `presence` row
(per-WS connection) and `peerState` row (cross-session, but CLI-written
only). There's no way for:
- An admin to assign someone's role/groups from the dashboard
- A user to set their profile once and forget about it
- An invite to pre-configure a new member's identity
- The dashboard to show/manage who belongs to which groups
This creates friction for daily users and makes managed teams impossible.
---
## Design
### Move identity to `member` (persistent, server-side)
| Field | Current location | New location | Source of truth |
|---|---|---|---|
| `displayName` | presence (ephemeral) | **member** (persistent) | Server, CLI flag overrides per-session |
| `roleTag` | nowhere (CLI `--role` flag only) | **member** (persistent) | Server, CLI flag overrides per-session |
| `groups` | peerState (CLI-written) | **member** (persistent) | Server, CLI flag overrides per-session |
| `messageMode` | config.json (local file) | **member** (persistent) | Server, CLI flag overrides per-session |
| `status` | presence | presence (no change) | Ephemeral, changes per-minute |
| `summary` | presence | presence (no change) | Ephemeral, changes per-task |
| `cwd`, `pid` | presence | presence (no change) | Literal session metadata |
### Three-layer model
```
member (persistent, server-side)
│ Source of truth for identity. Set via dashboard, CLI profile command,
│ or invite presets. Survives everything.
├── peerState (cross-session, server-side)
│ Cumulative stats, visibility toggle, last-seen metadata.
│ Still CLI-written. Not promoted — these are operational, not identity.
└── presence (ephemeral, per-connection)
Runtime snapshot. Copies member defaults on connect.
CLI flags override for this session only.
Status, summary, cwd, pid — all transient.
```
---
## Schema changes
### Extend `mesh.member`
```sql
ALTER TABLE mesh.member
ADD COLUMN role_tag TEXT, -- free-text label (lead, backend-dev, observer)
ADD COLUMN default_groups JSONB DEFAULT '[]', -- [{name: string, role?: string}]
ADD COLUMN message_mode TEXT DEFAULT 'push', -- push | inbox | off
ADD COLUMN dashboard_user_id TEXT; -- links to Payload CMS user.id (for CLI sync)
CREATE INDEX member_dashboard_user_idx
ON mesh.member(dashboard_user_id)
WHERE dashboard_user_id IS NOT NULL;
```
**Note:** `member.displayName` already exists. `member.role` stays as the
permission level enum (admin/member). `role_tag` is the new free-text label.
### Rename for clarity
The existing `member.role` (admin/member enum) controls **permissions**.
The new `member.role_tag` is a **label** visible to peers. To avoid
confusion in code and UI:
```
member.role → member.permission -- "admin" | "member" (access control)
member.role_tag → member.roleTag -- "backend-dev", "lead", etc. (display label)
```
**DB migration:** rename the column for clarity:
```sql
ALTER TABLE mesh.member RENAME COLUMN role TO permission;
-- Also rename the enum type if feasible, or keep as-is (DB enum name is internal)
```
**Impact:** Update all broker code that references `member.role` to
`member.permission`. The `meshRoleEnum` values stay the same (admin/member).
### Extend `mesh.mesh` — self-edit policy
```sql
ALTER TABLE mesh.mesh
ADD COLUMN self_editable JSONB DEFAULT '{
"displayName": true,
"roleTag": true,
"groups": true,
"messageMode": true
}';
```
Controls what members can edit about themselves. Admins can always edit
anyone. Mesh creator configures this on the dashboard.
### Extend `mesh.invite` — presets
```sql
ALTER TABLE mesh.invite
ADD COLUMN preset JSONB DEFAULT '{}';
```
Preset schema:
```typescript
interface InvitePreset {
displayName?: string; // rarely set — joiner usually picks their own
roleTag?: string; // "backend-dev", "observer", etc.
groups?: Array<{ name: string; role?: string }>;
messageMode?: "push" | "inbox" | "off";
}
```
When a member joins via this invite, preset values are applied to the
member row as defaults. The joiner can change them later (if self-editable).
---
## Permission model
### Who can edit what
| Action | Who | Condition |
|---|---|---|
| Edit your own `displayName` | You | `mesh.selfEditable.displayName` is true |
| Edit your own `roleTag` | You | `mesh.selfEditable.roleTag` is true |
| Edit your own `groups` | You | `mesh.selfEditable.groups` is true |
| Edit your own `messageMode` | You | `mesh.selfEditable.messageMode` is true |
| Edit **any member's** profile fields | Mesh admins | Always |
| Change `permission` (admin ↔ member) | Mesh admins | Always |
| Revoke a member | Mesh admins | Always |
| Change `selfEditable` policy | Mesh admins | Always |
### Default policy by tier
| Field | free | pro | team | enterprise |
|---|---|---|---|---|
| `displayName` | self | self | self | self |
| `roleTag` | self | self | admin-only | admin-only |
| `groups` | self | self | admin-only | admin-only |
| `messageMode` | self | self | self | self |
These are defaults — the mesh creator can override any of them on the
dashboard regardless of tier.
---
## Broker changes
### New HTTP endpoints
#### `PATCH /mesh/:meshId/member/:memberId`
Update a member's profile fields. Used by dashboard and CLI.
```typescript
// Request
PATCH /mesh/:meshId/member/:memberId
Authorization: Bearer <dashboard-session-token> OR X-Pubkey + X-Signature
{
"displayName": "Alice",
"roleTag": "lead",
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
"messageMode": "push"
}
// Response
{
"ok": true,
"member": {
"id": "member_123",
"displayName": "Alice",
"roleTag": "lead",
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
"messageMode": "push",
"permission": "admin"
}
}
```
**Authorization logic:**
```
if (caller is dashboard admin OR caller.memberId == targetMemberId with admin permission):
→ allow all fields
elif (caller.memberId == targetMemberId):
→ check mesh.selfEditable for each field
→ reject fields that are admin-only: 403 "field X is admin-managed in this mesh"
else:
→ 403 "not authorized"
```
**Side effect:** If the target member has active WebSocket connections,
push a `profile_updated` event to all their sessions:
```json
{
"type": "profile_updated",
"memberId": "member_123",
"changes": {
"roleTag": "lead",
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }]
}
}
```
The CLI handles this by updating its in-memory state for the current session.
#### `GET /mesh/:meshId/members`
List all members with their profiles. Used by dashboard and CLI.
```typescript
// Response
{
"ok": true,
"members": [
{
"id": "member_123",
"displayName": "Alice",
"roleTag": "lead",
"groups": [{ "name": "eng", "role": "lead" }],
"messageMode": "push",
"permission": "admin",
"dashboardUserId": "user_abc123",
"joinedAt": "2026-04-01T10:00:00Z",
"lastSeenAt": "2026-04-08T14:30:00Z",
"online": true,
"sessionCount": 2
},
{
"id": "member_456",
"displayName": "Bob",
"roleTag": "backend-dev",
"groups": [{ "name": "eng" }],
"messageMode": "inbox",
"permission": "member",
"dashboardUserId": null,
"joinedAt": "2026-04-03T09:00:00Z",
"lastSeenAt": "2026-04-07T18:00:00Z",
"online": false,
"sessionCount": 0
}
]
}
```
`online` and `sessionCount` are derived from active `presence` rows
(disconnectedAt IS NULL) for each member.
#### `PATCH /mesh/:meshId/settings`
Update mesh settings including self-edit policy. Dashboard only, admin only.
```typescript
// Request
PATCH /mesh/:meshId/settings
{
"selfEditable": {
"displayName": true,
"roleTag": false,
"groups": false,
"messageMode": true
}
}
```
### hello_ack changes
When a peer connects, the `hello_ack` now includes the member's persistent
profile so the CLI can apply defaults:
```json
{
"type": "hello_ack",
"presenceId": "pres_789",
"memberDisplayName": "Alice",
"memberProfile": {
"roleTag": "lead",
"groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
"messageMode": "push"
},
"meshPolicy": {
"selfEditable": { "displayName": true, "roleTag": false, "groups": false, "messageMode": true }
},
"restored": { ... }
}
```
### Presence creation changes
When creating a `presence` row on hello, the broker now merges:
```
1. Start with member defaults (displayName, roleTag → groups, messageMode)
2. Override with CLI hello payload (if flags were provided)
3. Write to presence row
```
This means `presence.groups` is populated from `member.default_groups` if
the CLI didn't send explicit groups in the hello. No more blank sessions.
### Join flow changes
When a member joins via `/join`, the broker applies invite presets:
```typescript
// In handleJoinPost, after creating the member row:
if (invite.preset) {
const preset = invite.preset;
await db.update(meshMember)
.set({
roleTag: preset.roleTag ?? null,
defaultGroups: preset.groups ?? [],
messageMode: preset.messageMode ?? "push",
// displayName already set from the join request
})
.where(eq(meshMember.id, newMemberId));
}
```
---
## CLI changes
### Launch flow (simplified)
```typescript
// After config loaded and mesh selected:
// 1. Connect to broker (existing flow)
// 2. Receive hello_ack with memberProfile + meshPolicy
// 3. Apply member defaults, CLI flags override
const effectiveName = flags.name ?? helloAck.memberDisplayName;
const effectiveRole = flags.role ?? helloAck.memberProfile.roleTag;
const effectiveGroups = flags.groups ?? helloAck.memberProfile.groups;
const effectiveMode = flags.messageMode ?? helloAck.memberProfile.messageMode;
// 4. No prompts. Flags or server defaults. Done.
```
### `claudemesh profile` command
New command to view/edit your member profile from the CLI:
```bash
# View current profile
claudemesh profile
# Name: Alice
# Role: lead
# Groups: eng (lead), review
# Messages: push
# Mesh: dev-team (admin)
# Edit fields (sends PATCH to broker)
claudemesh profile --role-tag fullstack
claudemesh profile --groups eng,frontend,review
claudemesh profile --message-mode inbox
claudemesh profile --name "Alice M."
# Edit another member (admin only)
claudemesh profile --member Bob --role-tag junior-dev --groups onboarding
```
Fields that are admin-managed show a lock icon:
```bash
claudemesh profile
# Name: Alice
# Role: lead 🔒 (admin-managed)
# Groups: eng (lead), review 🔒 (admin-managed)
# Messages: push
```
Attempting to edit a locked field:
```bash
claudemesh profile --role-tag senior
# Error: roleTag is admin-managed in this mesh. Ask a mesh admin to change it.
```
### First launch stores displayName
When `--name Alice` is provided on first launch (or sync), the CLI sends
it to the broker which persists it on the member row. Future launches
don't need `--name`:
```bash
# First time
claudemesh launch --name Alice
# → broker stores displayName="Alice" on member row
# Every subsequent launch
claudemesh launch
# → hello_ack returns displayName="Alice", no flag needed
```
---
## Invite presets
### Creating an invite with presets (dashboard)
```
Create invite link — dev-team
Permission: [member ▾] (admin/member)
Profile presets (applied to new members):
Role tag: [backend-dev ]
Groups: [eng ×] [review ×] [+ Add]
Message mode: (●) Push ( ) Inbox ( ) Off
Link settings:
Max uses: [10 ]
Expires: [7 days ▾]
[Generate link]
────────────────────
ic://join/eyJhbGciOi...
https://claudemesh.com/join/eyJhbGciOi...
[Copy link]
```
### Creating an invite with presets (CLI)
```bash
claudemesh invite create \
--role-tag backend-dev \
--groups eng,review \
--message-mode push \
--max-uses 10 \
--expires 7d
```
### Invite payload extension
The signed invite payload gains a `preset` field:
```json
{
"v": 1,
"mesh_id": "mesh_xyz",
"mesh_slug": "dev-team",
"broker_url": "wss://ic.claudemesh.com/ws",
"expires_at": 1713100000,
"mesh_root_key": "...",
"role": "member",
"preset": {
"roleTag": "backend-dev",
"groups": [{ "name": "eng" }, { "name": "review" }],
"messageMode": "push"
},
"owner_pubkey": "...",
"signature": "..."
}
```
The `preset` is included in the canonical signed bytes (appended to
the existing canonical format) so it can't be tampered with.
---
## Dashboard views
### Mesh members page
```
dev-team — Members
┌───────────────────────────────────────────────────────────────┐
│ Name Role tag Groups Status Access │
│─────────────────────────────────────────────────────────────── │
│ ● Alice lead eng, review idle admin ▾ │
│ ● Bob backend-dev eng working member ▾ │
│ ○ Carol designer design, ux — member ▾ │
│ ○ Dave — — — member ▾ │
└───────────────────────────────────────────────────────────────┘
● = online (has active session) ○ = offline
[Invite member]
```
Clicking a member opens an edit panel:
```
Edit member — Bob
Display name: [Bob ]
Role tag: [backend-dev ]
Groups: [eng ×] [+ Add]
Message mode: (●) Push ( ) Inbox ( ) Off
Permission: [member ▾]
Joined: Apr 3, 2026
Last seen: 2 hours ago
Sessions: 0 (offline)
[Save] [Revoke access]
```
### Mesh settings page
```
dev-team — Settings
General:
Name: [dev-team ]
Visibility: [private ▾]
Member self-edit permissions:
What can members edit about themselves?
Display name: [✓]
Role tag: [ ] ← only admins can assign
Groups: [ ] ← only admins can assign
Message mode: [✓]
[Save]
```
### Live presence view
```
dev-team — Live
┌────────────────────────────────────────────────────────────────┐
│ ● Alice (lead) idle │
│ eng (lead), review │
│ Session 1: ~/Desktop/claudemesh — "Working on auth sync" │
│ Session 2: ~/Desktop/cuidecar — "Reviewing PR #47" │
│ │
│ ● Bob (backend-dev) working │
│ eng │
│ Session 1: ~/Desktop/api — "Fixing migration bug" │
└────────────────────────────────────────────────────────────────┘
Auto-refreshes every 5s via WebSocket.
```
---
## Real-time profile push
When an admin (or self) updates a member's profile via the dashboard or
CLI, all active sessions for that member receive a push:
```
Dashboard: admin changes Bob's groups
→ PATCH /mesh/:meshId/member/:memberId { groups: [{name: "ops"}] }
→ Broker updates member row
→ Broker finds all active presence rows for this memberId
→ Broker sends to each WS connection:
{ type: "profile_updated", changes: { groups: [{name: "ops"}] } }
→ Bob's CLI receives push, updates in-memory groups
→ Bob's next list_peers / join_group reflects the change
→ No restart needed
```
---
## Migration from peerState
The existing `peerState` table stores `groups`, `profile`, `visible`,
`lastDisplayName`, and `cumulativeStats`. After this change:
| peerState field | Migration |
|---|---|
| `groups` | Copy to `member.default_groups` for existing members. peerState.groups becomes a session-level overlay (for CLI `join_group`/`leave_group` within a session). |
| `lastDisplayName` | Already on `member.displayName`. Drop from peerState. |
| `profile` (avatar, title, bio) | Keep on peerState for now. These are presentation, not identity. Could move to member later. |
| `visible` | Keep on peerState. Session-scoped toggle. |
| `cumulativeStats` | Keep on peerState. Operational data, not identity. |
**The peerState table is NOT removed.** It still serves its purpose for
cross-session operational state. The member table absorbs identity fields
only.
---
## Implementation order
1. **DB migration:** Add columns to `member` (role_tag, default_groups,
message_mode, dashboard_user_id), `mesh` (self_editable), `invite`
(preset). Rename `member.role``member.permission`.
2. **Broker:** `PATCH /mesh/:meshId/member/:memberId` endpoint with
self-edit permission checks and real-time push.
3. **Broker:** `GET /mesh/:meshId/members` endpoint with online status.
4. **Broker:** `PATCH /mesh/:meshId/settings` endpoint.
5. **Broker:** Update `handleHello` to include memberProfile + meshPolicy
in hello_ack. Update presence creation to merge member defaults.
6. **Broker:** Update `/join` to apply invite presets to new members.
7. **CLI:** Update launch to read memberProfile from hello_ack, skip
prompts when server has defaults, flags override.
8. **CLI:** `claudemesh profile` command.
9. **CLI:** Update invite creation to accept preset flags.
10. **Web:** Member management page (list, edit, revoke).
11. **Web:** Mesh settings page (self-edit policy).
12. **Web:** Invite creation with presets.
13. **Web:** Live presence view.
---
## What stays the same
- Ed25519 keypairs remain the mesh identity
- E2E encryption unchanged (crypto_box with peer keys)
- `presence` table stays ephemeral — status, summary, cwd, pid
- `peerState` keeps operational data — stats, visibility, session profile
- `list_peers` MCP tool still works (reads from presence, now enriched
with member defaults)
- CLI `--role`, `--groups`, `--message-mode` flags still work as
per-session overrides
- `join_group` / `leave_group` WS messages still work for session-scoped
group changes (these update presence, not member)
---
## Open questions
1. **Session-scoped group changes vs member-level groups.** If member has
`groups: [eng]` and the CLI does `join_group("review")` mid-session,
does that add to the member row or just the presence? **Proposal: just
presence.** Session-scoped join/leave is temporary. Use `claudemesh
profile --groups` or dashboard for permanent changes.
2. **Profile conflicts across devices.** If Alice has two CLI devices with
different keypairs (different member rows), they have independent
profiles. This is correct — they're different identities in the mesh.
But if she syncs from the same dashboard account, should her profile
sync across devices? **Proposal: no, not in v1.** Each member row is
independent. Dashboard shows all members linked to your account.
3. **Audit trail for profile changes.** Should profile edits go in the
audit log? **Proposal: yes.** Event type: `member_profile_updated`,
payload includes who changed what. Useful for managed teams.