fix(web): correct LinkedIn URL on about page
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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-09 13:17:24 +01:00
parent 05e3c43e29
commit 0661e6223a
28 changed files with 3409 additions and 8 deletions

663
docs/member-profile-spec.md Normal file
View File

@@ -0,0 +1,663 @@
# 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.