Compare commits
212 Commits
v0.5.1
...
45d85f5eaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45d85f5eaa | ||
|
|
ee12510ef1 | ||
|
|
c9ede3d469 | ||
|
|
b998e35d17 | ||
|
|
506c470441 | ||
|
|
b4703a482d | ||
|
|
29f546abcf | ||
|
|
5716a6ce22 | ||
|
|
d37516213a | ||
|
|
5b69de08da | ||
|
|
ccf95ff382 | ||
|
|
43f2728283 | ||
|
|
d33b8fc43b | ||
|
|
ce52fcef2d | ||
|
|
77ee1d0d80 | ||
|
|
2f27a5eef4 | ||
|
|
32851419e6 | ||
|
|
e2b6e53cc1 | ||
|
|
3595fc2c4d | ||
|
|
2825ef7151 | ||
|
|
a9858ef876 | ||
|
|
6836a495a4 | ||
|
|
07720f8f1e | ||
|
|
f4881b21b0 | ||
|
|
4561076904 | ||
|
|
0d53f2ae52 | ||
|
|
b328e78bd3 | ||
|
|
23604a125e | ||
|
|
b680260c8d | ||
|
|
b65a545ece | ||
|
|
d07cff788c | ||
|
|
bb1310167e | ||
|
|
ea4e3b03bb | ||
|
|
1a42c2ef09 | ||
|
|
43b70013c5 | ||
|
|
b8d8b5469b | ||
|
|
ab7fb6bd31 | ||
|
|
b2999878c4 | ||
|
|
a890a1d92e | ||
|
|
80a6b8b50f | ||
|
|
465ff9a10e | ||
|
|
0f46c787a7 | ||
|
|
a365fef170 | ||
|
|
ca441dae45 | ||
|
|
ac709dbe92 | ||
|
|
d0fbc64e7e | ||
|
|
f1d35b10da | ||
|
|
5e97d48cd5 | ||
|
|
c8ae6462e3 | ||
|
|
fb7a84aed6 | ||
|
|
c1fa3bcb5c | ||
|
|
dbea96960f | ||
|
|
a022da1998 | ||
|
|
5df2664bae | ||
|
|
816c42feae | ||
|
|
4c0a417b7c | ||
|
|
e6962f1454 | ||
|
|
1d506f3ea5 | ||
|
|
64266a75f7 | ||
|
|
2710f354a9 | ||
|
|
6b55859d38 | ||
|
|
7d31cc6283 | ||
|
|
0403cfeb76 | ||
|
|
d8e6900072 | ||
|
|
ed8dab8bd3 | ||
|
|
dad51870d9 | ||
|
|
a6af0f2154 | ||
|
|
0661e6223a | ||
|
|
05e3c43e29 | ||
|
|
e3fa6e6a5e | ||
|
|
17066b4f6c | ||
|
|
8d1685e64d | ||
|
|
bb28e16c7d | ||
|
|
ac59d2acfe | ||
|
|
0a1af84712 | ||
|
|
18dc29aba1 | ||
|
|
795217093f | ||
|
|
61b0813924 | ||
|
|
c10337ab9f | ||
|
|
126bbfeb2c | ||
|
|
c914f2b7db | ||
|
|
a8b9348b36 | ||
|
|
c3dd4efe82 | ||
|
|
a7d9ecab15 | ||
|
|
d263fe0f26 | ||
|
|
3226493e6d | ||
|
|
4cb5a97512 | ||
|
|
c080bc517f | ||
|
|
471e88b3e6 | ||
|
|
c66e3adf67 | ||
|
|
3f46a6657a | ||
|
|
83ba1aa373 | ||
|
|
7430e4ffe0 | ||
|
|
d72e49b8fd | ||
|
|
3f57944921 | ||
|
|
b31aab8aeb | ||
|
|
5db9842261 | ||
|
|
81e520fdbb | ||
|
|
26c4502277 | ||
|
|
bfc62b9a72 | ||
|
|
f8c6f9ae74 | ||
|
|
3497700fad | ||
|
|
2c156f832e | ||
|
|
4ee810242d | ||
|
|
b6224c4186 | ||
|
|
4c385a16cc | ||
|
|
4ae6a86bf6 | ||
|
|
c327c282e3 | ||
|
|
e645455b22 | ||
|
|
45505a1635 | ||
|
|
17e6361d64 | ||
|
|
528e7e21b1 | ||
|
|
7b875de301 | ||
|
|
8a3c96dc7c | ||
|
|
b0634b829c | ||
|
|
2bd388a5e2 | ||
|
|
71c0767a1b | ||
|
|
6a3f087209 | ||
|
|
873f588057 | ||
|
|
070a3b7422 | ||
|
|
75ca892ea7 | ||
|
|
a90046a8e3 | ||
|
|
02a165dd76 | ||
|
|
52393429f9 | ||
|
|
9474d985ae | ||
|
|
643c808685 | ||
|
|
2c24f667f9 | ||
|
|
b0113913f2 | ||
|
|
e1cafa54b3 | ||
|
|
a4f2e0aa81 | ||
|
|
cbcde4d910 | ||
|
|
495c234159 | ||
|
|
42c1d02f5e | ||
|
|
a33c925216 | ||
|
|
6ab3fbbea3 | ||
|
|
26adbafde2 | ||
|
|
13e8ce07ac | ||
|
|
5398ca6833 | ||
|
|
56b1cc0756 | ||
|
|
fc8a7edc23 | ||
|
|
e09671cdcb | ||
|
|
32fc4a0c98 | ||
|
|
b315b31cc9 | ||
|
|
21cb6efced | ||
|
|
125b576e2c | ||
|
|
3641618391 | ||
|
|
a92cf6b629 | ||
|
|
2c9c8c7b6c | ||
|
|
98fda20ab6 | ||
|
|
025a53a70c | ||
|
|
b55cf269a4 | ||
|
|
504111c50c | ||
|
|
05d9b56f28 | ||
|
|
c8cb1e3ea5 | ||
|
|
86a258301f | ||
|
|
7e102a235b | ||
|
|
5563f90733 | ||
|
|
b3b9972e60 | ||
|
|
fe9285351b | ||
|
|
08e289a5e3 | ||
|
|
7d432b3aaa | ||
|
|
b0dc538119 | ||
|
|
27c9d2a02c | ||
|
|
87e0d0004d | ||
|
|
dba0fb7b33 | ||
|
|
72be651ca8 | ||
|
|
db2bf3ea06 | ||
|
|
e87380775f | ||
|
|
58ba01f20f | ||
|
|
59332dc47d | ||
|
|
f34b8fbc6b | ||
|
|
79525af42e | ||
|
|
69e93d4b8c | ||
|
|
810f372d1c | ||
|
|
453705a4e1 | ||
|
|
5cb4cc4fe7 | ||
|
|
eeac47c360 | ||
|
|
0bb9d71a26 | ||
|
|
3ff7a61e3f | ||
|
|
e76ade64d2 | ||
|
|
59848f0d3e | ||
|
|
d0fa1c028f | ||
|
|
8f925d9a9e | ||
|
|
4ce1034dcd | ||
|
|
e26a36e543 | ||
|
|
60c74d9463 | ||
|
|
6fba9bd4eb | ||
|
|
5bcc1fe323 | ||
|
|
e70f0ed1ff | ||
|
|
5f696f47ea | ||
|
|
ccb9fb2a68 | ||
|
|
898c061089 | ||
|
|
f7a6559429 | ||
|
|
579d0c3d3e | ||
|
|
190f5a958e | ||
|
|
03661e1b68 | ||
|
|
d451fc296e | ||
|
|
3da5d71275 | ||
|
|
cdf335f609 | ||
|
|
0cd16ff358 | ||
|
|
3e9707276d | ||
|
|
82cfee315c | ||
|
|
ab08be04a5 | ||
|
|
ee585a8370 | ||
|
|
1f078bf0c8 | ||
|
|
2372032a68 | ||
|
|
a70c5fd124 | ||
|
|
5c62d287cf | ||
|
|
9ae378c2e3 | ||
|
|
7381738f0b | ||
|
|
8c6b0c0e07 | ||
|
|
ec9626503c |
@@ -0,0 +1,243 @@
|
||||
# CLI Wizard Architecture Refactor
|
||||
|
||||
**Status:** backlog
|
||||
**Created:** 2026-04-10
|
||||
**Source:** Reverse-engineered from `@posthog/wizard` (npm cache), applied to `apps/cli/src/commands/launch.ts`
|
||||
|
||||
## Why
|
||||
|
||||
Launch wizard has three compounding problems:
|
||||
|
||||
1. **Imperative branching** — `launch.ts` checks account → mesh → name → role → exec in hardcoded order. Adding a screen requires touching existing code. Hard to reason about `--resume`, `--non-interactive`, and skip conditions.
|
||||
2. **Terminal bleed-through on handoff** — wizard→`claude` exec corrupts Ink's TUI state (garbled word wraps, tool labels overwritten, spinner fragments fused to paths). Root cause is spread across multiple exit paths instead of one choke point.
|
||||
3. **Inconsistent visual design** — ad-hoc colors per file, no central palette, no shared icon set, no shared layout primitives. Every screen reinvents status rows, centering, and spacing.
|
||||
|
||||
PostHog's wizard solves all three with one architectural pattern: **declarative flow pipelines + session-as-store + shared visual primitives**. This artifact captures the plan to port that pattern.
|
||||
|
||||
## What PostHog does (the reference)
|
||||
|
||||
### Flow pipeline (`flows.ts` + `router.ts`)
|
||||
|
||||
Each wizard flow is an array of screen entries:
|
||||
|
||||
```ts
|
||||
export const FLOWS = {
|
||||
[Flow.Wizard]: [
|
||||
{ screen: Screen.Intro, isComplete: s => s.setupConfirmed },
|
||||
{ screen: Screen.HealthCheck, isComplete: s => s.readinessResult !== null },
|
||||
{ screen: Screen.Setup, show: needsSetup, isComplete: s => !needsSetup(s) },
|
||||
{ screen: Screen.Auth, isComplete: s => s.credentials !== null },
|
||||
{ screen: Screen.Run, isComplete: s => s.runPhase === RunPhase.Completed },
|
||||
{ screen: Screen.Outro, isComplete: s => s.outroDismissed },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
The router walks the array, skips entries where `show(s) === false` or `isComplete(s) === true`, and returns the first remaining entry. Zero switch statements. Zero hardcoded transitions. Adding a screen = appending an object.
|
||||
|
||||
### Overlay stack
|
||||
|
||||
Separate from the linear flow cursor. Interrupts (port conflict, auth expired, managed settings) are pushed onto `overlays[]` from anywhere and popped when dismissed. Active screen = top of overlay stack OR flow cursor. Flows never need to know about interrupts.
|
||||
|
||||
### Session as single source of truth
|
||||
|
||||
One `WizardStore` holds all session state. Screens subscribe via React 18 `useSyncExternalStore`. Completion predicates read session; imperative code writes session; the router re-resolves on every change.
|
||||
|
||||
### Visual primitives
|
||||
|
||||
- `styles.ts` — 6-color palette (`Colors`), 9-icon set (`Icons`), alignment enums (`HAlign`, `VAlign`)
|
||||
- `CardLayout` — semantic centering wrapper used by every screen
|
||||
- `PickerMenu` — the only selection primitive, used for every choice
|
||||
- `screen-registry.ts` — maps `Screen` enum → React component
|
||||
- Brand mark: three colored `█` blocks next to the wizard name on every screen header
|
||||
|
||||
## What claudemesh should do
|
||||
|
||||
### Target file layout
|
||||
|
||||
```
|
||||
apps/cli/src/
|
||||
├── commands/
|
||||
│ └── launch.ts # thin entrypoint: parse flags → start TUI
|
||||
└── ui/
|
||||
├── styles.ts # palette, icons, alignment enums
|
||||
├── store.ts # LaunchStore (session + subscribe)
|
||||
├── router.ts # flow cursor + overlay stack
|
||||
├── flows.ts # FLOWS = { Launch: [...], Join: [...] }
|
||||
├── screen-registry.ts # Screen enum → component
|
||||
├── primitives/
|
||||
│ ├── CardLayout.tsx
|
||||
│ ├── PickerMenu.tsx
|
||||
│ ├── StatusRows.tsx # new: "Directory ✓ /claudemesh" pattern
|
||||
│ ├── BrandMark.tsx # new: 3 colored squares + label
|
||||
│ └── LoadingBox.tsx
|
||||
└── screens/
|
||||
├── WelcomeScreen.tsx
|
||||
├── AccountScreen.tsx
|
||||
├── MeshPickerScreen.tsx
|
||||
├── NameRoleScreen.tsx
|
||||
├── ConfirmScreen.tsx
|
||||
└── HandoffScreen.tsx # last screen; its unmount triggers exec claude
|
||||
```
|
||||
|
||||
### Flow definition
|
||||
|
||||
```ts
|
||||
export const FLOWS = {
|
||||
[Flow.Launch]: [
|
||||
{ screen: Screen.Welcome, isComplete: s => s.welcomed },
|
||||
{ screen: Screen.Account, show: s => !s.hasAccount, isComplete: s => s.hasAccount },
|
||||
{ screen: Screen.MeshPicker, show: s => s.meshes.length > 1, isComplete: s => s.meshSlug !== null },
|
||||
{ screen: Screen.NameRole, isComplete: s => s.displayName !== null && s.role !== null },
|
||||
{ screen: Screen.Confirm, isComplete: s => s.confirmed },
|
||||
{ screen: Screen.Handoff, isComplete: () => false }, // terminal screen
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### `--resume` works for free
|
||||
|
||||
`--resume <id>` populates the session from saved state; every satisfied predicate auto-skips. The wizard renders only the screens that still need input. No special `--resume` branches in screen code.
|
||||
|
||||
### `--non-interactive` works for free
|
||||
|
||||
Non-interactive mode: walk the flow, for each incomplete entry check if its required session fields can be sourced from CLI flags. If yes, populate and continue. If no, **fail fast with a clear message** naming the missing flag. Never silently guess defaults.
|
||||
|
||||
```
|
||||
$ claudemesh launch --non-interactive --name Alexis
|
||||
✗ Missing --mesh (required in non-interactive mode when >1 mesh joined)
|
||||
Available meshes: alexis-mou, dev, staging
|
||||
```
|
||||
|
||||
### Overlay interrupts claudemesh needs
|
||||
|
||||
- `BrokerDisconnect` — WS dropped mid-wizard, retry countdown
|
||||
- `InviteInvalid` — paste invite screen rejected token
|
||||
- `MeshNotFound` — `--mesh foo` passed but not joined
|
||||
- `RateLimit` — broker rate limited the CLI, backoff timer
|
||||
- `UpdateAvailable` — newer CLI version on npm, non-blocking banner
|
||||
|
||||
### Terminal handoff choke point
|
||||
|
||||
The last flow entry (`Screen.Handoff`) renders a brief "Launching Claude Code…" card, then:
|
||||
|
||||
```ts
|
||||
// apps/cli/src/ui/screens/HandoffScreen.tsx (on mount)
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await inkApp.unmount();
|
||||
await inkApp.waitUntilExit();
|
||||
resetTerminal(); // single choke point for ANSI teardown
|
||||
await flushStdout();
|
||||
execa('claude', claudeArgs, { stdio: 'inherit' });
|
||||
})();
|
||||
}, []);
|
||||
```
|
||||
|
||||
`resetTerminal()` lives in `apps/cli/src/ui/terminal.ts`:
|
||||
|
||||
```ts
|
||||
export function resetTerminal() {
|
||||
process.stdout.write(
|
||||
'\x1b[0m' + // reset SGR
|
||||
'\x1b[?25h' + // show cursor
|
||||
'\x1b[?1049l' + // exit alt-screen
|
||||
'\x1b[?1000l' + // disable mouse tracking
|
||||
'\x1b[?1002l' +
|
||||
'\x1b[?1003l' +
|
||||
'\x1b[?1006l' +
|
||||
'\x1b[?2004l' + // disable bracketed paste
|
||||
'\x1b[2J' + // clear screen
|
||||
'\x1b[H' // cursor home
|
||||
);
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
||||
}
|
||||
```
|
||||
|
||||
PostHog only does SGR reset + clear + home on unmount — they don't hand off to another full-screen app, so that's enough for them. Claudemesh needs the full mode-reset because Claude Code takes over the TTY.
|
||||
|
||||
### Visual design system
|
||||
|
||||
`apps/cli/src/ui/styles.ts`:
|
||||
|
||||
```ts
|
||||
export const Colors = {
|
||||
primary: 'cyan',
|
||||
accent: '#7C3AED', // claudemesh purple
|
||||
title: '#4C1D95',
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
warning: 'yellow',
|
||||
muted: 'gray',
|
||||
} as const;
|
||||
|
||||
export const Icons = {
|
||||
check: '✔',
|
||||
cross: '✘',
|
||||
warning: '⚠',
|
||||
arrow: '▶',
|
||||
smallArrow: '▸',
|
||||
bullet: '•',
|
||||
diamond: '◆',
|
||||
square: '█',
|
||||
} as const;
|
||||
|
||||
export enum HAlign { Left = 'flex-start', Center = 'center', Right = 'flex-end' }
|
||||
export enum VAlign { Top = 'flex-start', Center = 'center', Bottom = 'flex-end' }
|
||||
```
|
||||
|
||||
Every screen imports from here. No inline color strings allowed.
|
||||
|
||||
### Status rows pattern
|
||||
|
||||
Replaces the current plain-text banner:
|
||||
|
||||
```
|
||||
██ claudemesh launch
|
||||
|
||||
Directory ✔ /claudemesh
|
||||
Account ✔ agutierrez@mineryreport.com
|
||||
Mesh ✔ alexis-mou (9 peers online)
|
||||
Name ✔ Alexis
|
||||
Role ▸ (pick one)
|
||||
|
||||
▸ Continue
|
||||
Change mesh
|
||||
Cancel
|
||||
```
|
||||
|
||||
## Implementation order
|
||||
|
||||
| # | Impact | Effort | Scope |
|
||||
|---|---|---|---|
|
||||
| 1 | High | S | `ui/styles.ts` — palette + icons + alignment enums; migrate existing screens |
|
||||
| 2 | High | S | `ui/primitives/StatusRows.tsx` + `BrandMark.tsx` |
|
||||
| 3 | High | M | `ui/store.ts` + `ui/router.ts` + `ui/flows.ts` (flow pipeline core) |
|
||||
| 4 | High | M | Refactor `launch.ts` to render through router; port existing screens |
|
||||
| 5 | High | S | `HandoffScreen` + `resetTerminal()` choke point — fixes TUI bleed bug |
|
||||
| 6 | High | S | Preselect "Continue" on every confirmation screen (one-keypress happy path) |
|
||||
| 7 | Med | M | Overlay stack + first two overlays (`BrokerDisconnect`, `InviteInvalid`) |
|
||||
| 8 | Med | M | `--non-interactive` mode using flow walker + fail-fast flag check |
|
||||
| 9 | Med | S | Per-mesh/per-role `preRunNotice` extension point |
|
||||
| 10| Low | L | `DissolveTransition` / `ContentSequencer` polish primitives |
|
||||
|
||||
Steps 1–5 are the atomic unit of value: they fix the bleed-through bug, establish the visual system, and unblock everything else. Should ship as one PR.
|
||||
Steps 6–9 can each ship independently.
|
||||
Step 10 is polish — defer until after v0.2.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Ink version**: current CLI uses Ink 4.x? PostHog is on Ink 5 with `useSyncExternalStore`. Check `apps/cli/package.json` before porting the store pattern — Ink 4 needs a different subscription approach.
|
||||
- **React version**: `useSyncExternalStore` is React 18+. Confirm.
|
||||
- **Flow granularity**: should `Join` (paste invite) be a separate flow from `Launch`, or an overlay inside `Launch`? PostHog-style: separate flow triggered from the welcome screen. Simpler.
|
||||
- **Resume semantics**: does `--resume <id>` resume the *Claude* session only, or also restore the wizard's last mesh/name/role choice? If the latter, need a `~/.claudemesh/sessions/<id>.json` alongside Claude's own session file.
|
||||
|
||||
## References
|
||||
|
||||
- PostHog wizard source: `~/.npm/_npx/b48b11b34a0cada0/node_modules/@posthog/wizard/dist/src/ui/tui/`
|
||||
- `start-tui.js` — Ink bootstrap + cleanup
|
||||
- `router.js` — flow cursor + overlay stack
|
||||
- `flows.js` — declarative pipeline definition
|
||||
- `styles.js` — palette + icons
|
||||
- `screens/IntroScreen.js` — reference for status rows + picker
|
||||
- `primitives/CardLayout.js` — semantic centering
|
||||
820
.artifacts/backlog/2026-04-11-v1-feature-inventory.md
Normal file
@@ -0,0 +1,820 @@
|
||||
# claudemesh v1 — Feature Inventory
|
||||
|
||||
**Status:** backlog reference
|
||||
**Created:** 2026-04-11
|
||||
**Purpose:** Exhaustive audit of what v1 ships today. **Every row in this document must still work after v2 lands.** v2 is a refactor + CLI user flows, NOT a functional rewrite; this inventory is the regression checklist.
|
||||
|
||||
**Source of truth**:
|
||||
- `apps/cli/src/` — 22 files, ~12 k LOC (v0.10.5)
|
||||
- `apps/broker/src/` — 23 files, ~11 k LOC
|
||||
- `packages/db/src/schema/mesh.ts` — 1,019 lines, 23 tables
|
||||
|
||||
---
|
||||
|
||||
## 0. Summary counts
|
||||
|
||||
| Surface | v1 count |
|
||||
|---|---|
|
||||
| CLI commands (subcommands in `index.ts`) | 23 |
|
||||
| MCP tools (handlers in `mcp/server.ts`) | 79 |
|
||||
| Broker WS message types (dispatched in `index.ts`) | 85 |
|
||||
| Broker HTTP endpoints | 18 |
|
||||
| Postgres tables in `mesh` schema | 23 |
|
||||
| External backend services the broker manages | 5 (Postgres, Neo4j, Qdrant, MinIO, Docker) |
|
||||
| Lines of source (CLI + broker, excluding tests) | ~23,450 |
|
||||
|
||||
---
|
||||
|
||||
## 1. CLI commands
|
||||
|
||||
All dispatched from `apps/cli/src/index.ts`. v1 ships 23 public subcommands plus the bare-command welcome wizard.
|
||||
|
||||
| Command | File | Purpose | Flags / args |
|
||||
|---|---|---|---|
|
||||
| `claudemesh` (bare) | `commands/welcome.ts` | Interactive welcome wizard. Entry point for new users. | (none) |
|
||||
| `launch` | `commands/launch.ts` (775 lines, biggest) | Spawn a Claude Code session with mesh connectivity + MCP tools | `--name`, `--role`, `--groups`, `--mesh`, `--join`, `--message-mode`, `--system-prompt`, `-y/--yes`, `-r/--resume`, `-c/--continue`, `--quiet`, + passthrough to `claude` after `--` |
|
||||
| `create` | `commands/create.ts` | Create a new mesh from a template | `--template`, `--list-templates` |
|
||||
| `install` | `commands/install.ts` (538 lines) | Register MCP server + status hooks with Claude Code (`~/.claude.json`, `~/.claude/settings.json`) | `--no-hooks` |
|
||||
| `uninstall` | `commands/install.ts` | Remove MCP server + hooks from Claude Code config | (none) |
|
||||
| `join` | `commands/join.ts` (193 lines) | Join a mesh via invite URL or token | positional `<url>` |
|
||||
| `list` | `commands/list.ts` | Show joined meshes, slugs, local identities | (none) |
|
||||
| `leave` | `commands/leave.ts` | Leave a joined mesh + remove its local keypair | positional `<slug>` |
|
||||
| `peers` | `commands/peers.ts` | List online peers with status, summary, groups | `--mesh`, `--json` |
|
||||
| `send` | `commands/send.ts` | Send a message to a peer, group, or all peers | positional `<to> <message>`, `--mesh`, `--priority` |
|
||||
| `inbox` | `commands/inbox.ts` | Drain pending inbound messages | `--mesh`, `--json`, `--wait` |
|
||||
| `state` | `commands/state.ts` | Get / set / list shared KV state in the mesh | positional `<action> <key> [value]`, `--mesh`, `--json` |
|
||||
| `info` | `commands/info.ts` | Mesh overview: slug, broker, peer count, state keys | `--mesh`, `--json` |
|
||||
| `remember` | `commands/memory.ts` | Store a persistent memory visible to all peers | positional `<content>`, `--mesh`, `--tags`, `--json` |
|
||||
| `recall` | `commands/memory.ts` | Full-text search of mesh memories | positional `<query>`, `--mesh`, `--json` |
|
||||
| `remind` | `commands/remind.ts` (142 lines) | Schedule a delayed message. Also: `remind list`, `remind cancel <id>` | positional `<message>`, `--in`, `--at`, `--cron`, `--to`, `--mesh`, `--json` |
|
||||
| `sync` | `commands/sync.ts` | Sync meshes from the user's claudemesh.com dashboard account | `--force` |
|
||||
| `profile` | `commands/profile.ts` | View or edit member profile (self or another member if admin) | `--mesh`, `--role-tag`, `--groups`, `--message-mode`, `--name`, `--member`, `--json` |
|
||||
| `status` | `commands/status.ts` | Check broker connectivity for each joined mesh | (none) |
|
||||
| `doctor` | `commands/doctor.ts` (212 lines) | Diagnose install, config, keypairs, PATH | 7 checks: Node >= 20, claude binary, MCP registered, hooks registered, config parses, file perms, keypairs valid |
|
||||
| `mcp` | `mcp/server.ts` (2139 lines) | Start MCP server on stdio (internal — invoked by Claude Code) | (none) |
|
||||
| `seed-test-mesh` | `commands/seed-test-mesh.ts` | Dev-only: inject a mesh into local config without invite flow | `<slug>`, `<broker_url>` |
|
||||
| `hook` | `commands/hook.ts` | Internal: handle Claude Code hook events (status updates from session lifecycle) | stdin JSON from Claude Code |
|
||||
| `connect telegram` | `commands/connect-telegram.ts` | Link a Telegram bot to a mesh | inline token prompts, calls broker `/tg/token` |
|
||||
| `disconnect telegram` | `commands/disconnect-telegram.ts` | Unlink Telegram bot | (none) |
|
||||
|
||||
### Flag-first invocation rewrite
|
||||
|
||||
`apps/cli/src/index.ts` lines 339–355 implement a **friction reducer**: if the user types `claudemesh --resume xxx` or any flag-first invocation, the argv is rewritten to `claudemesh launch --resume xxx` before citty parses it. This lets users skip typing `launch` for common flag-only forms.
|
||||
|
||||
**Must preserve in v2.** Users may depend on this. Applies to `--resume`, `--continue`, `-y`, `--mesh`, `--name`, etc.
|
||||
|
||||
---
|
||||
|
||||
## 2. MCP tools (79 total)
|
||||
|
||||
Defined in `apps/cli/src/mcp/tools.ts` with schemas, implemented in `apps/cli/src/mcp/server.ts` with per-tool case handlers. Each MCP tool is a RPC that the CLI's MCP server handles locally or forwards to the broker via WS.
|
||||
|
||||
Grouped by domain family. Every tool listed here has a working handler in v1.
|
||||
|
||||
### 2.1 Messaging (4)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `send_message` | Send encrypted message to peer, group, or broadcast. Supports priorities: `now` (immediate), `next` (default), `low`. Broker queues if recipient offline. |
|
||||
| `list_peers` | List connected peers in the mesh with `presenceId`, `displayName`, `status`, `summary`, `groups`, `roleTag`. |
|
||||
| `message_status` | Query delivery state of a sent message by `messageId`. |
|
||||
| `check_messages` | Drain pending inbox messages (push mode). |
|
||||
|
||||
### 2.2 Profile + identity (4)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `set_summary` | Set the current peer's work summary (visible to others). |
|
||||
| `set_status` | Set status: `idle`, `working`, `dnd`. Priority-ranked by source (`hook` > `manual` > `jsonl`). |
|
||||
| `set_visible` | Toggle visibility. Hidden peers skip `list_peers` and broadcasts but still receive direct messages. |
|
||||
| `set_profile` | Update display name, role tag, groups, avatar, title, bio, capabilities. |
|
||||
|
||||
### 2.3 Groups (2)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `join_group` | Join a `@group` with optional role (`lead`, `member`, or free-form). |
|
||||
| `leave_group` | Leave a `@group`. |
|
||||
|
||||
### 2.4 State KV (3)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `set_state` | Set a key-value pair in the mesh's shared state. Broadcasts `state_change` push to all peers. |
|
||||
| `get_state` | Read a value by key. |
|
||||
| `list_state` | List all state keys with values, authors, timestamps. |
|
||||
|
||||
### 2.5 Memory (3)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `remember` | Store a text memory with optional tags. Persists across sessions. |
|
||||
| `recall` | Full-text search memories by query, ranked results. |
|
||||
| `forget` | Delete a memory by ID. |
|
||||
|
||||
### 2.6 Files (8)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `share_file` | Upload a file to MinIO. Supports `to: <peer>` for E2E encryption (symmetric key wrapped with peer pubkey), or mesh-wide sharing. Supports `persistent` vs `ephemeral` storage. |
|
||||
| `get_file` | Download a file by `fileId`. Returns a presigned MinIO URL. |
|
||||
| `list_files` | List files in the mesh by `scope`, `tags`, author. |
|
||||
| `file_status` | Query status of a file: who downloaded, when. |
|
||||
| `delete_file` | Delete a file (owner only). |
|
||||
| `grant_file_access` | Add another peer as a recipient of an already-encrypted file (re-wraps symmetric key). |
|
||||
| `read_peer_file` | Read a file from another peer's working directory (requires peer online + sharing). |
|
||||
| `list_peer_files` | List files in a peer's shared directory (tree of names, not contents). |
|
||||
|
||||
### 2.7 Vectors (Qdrant) (4)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `vector_store` | Store embedding with metadata in a named collection. |
|
||||
| `vector_search` | Nearest-neighbor search in a collection with `limit`. |
|
||||
| `vector_delete` | Delete a vector by ID. |
|
||||
| `list_collections` | List collections in the mesh's Qdrant namespace. |
|
||||
|
||||
### 2.8 Graph (Neo4j) (2)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `graph_query` | Read-only Cypher MATCH query on the per-mesh Neo4j database. |
|
||||
| `graph_execute` | Write Cypher (CREATE/MERGE/DELETE). |
|
||||
|
||||
### 2.9 Shared SQL (Postgres) (3)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `mesh_query` | SELECT-only query on the per-mesh Postgres schema. |
|
||||
| `mesh_execute` | DDL + DML (CREATE TABLE, INSERT, UPDATE, DELETE). |
|
||||
| `mesh_schema` | List tables + columns in the mesh's schema. |
|
||||
|
||||
### 2.10 Streams (4)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `create_stream` | Create a named stream for live data pub-sub. |
|
||||
| `publish` | Push data to a stream. Subscribers receive in real-time. |
|
||||
| `subscribe` | Subscribe to a stream. Events arrive as channel notifications. |
|
||||
| `list_streams` | List active streams. |
|
||||
|
||||
### 2.11 Contexts (3)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `share_context` | Share session understanding with the mesh (summary + files_read + key_findings + tags). |
|
||||
| `get_context` | Search contexts by query (file path, topic, etc.). |
|
||||
| `list_contexts` | Show what peers currently know about the codebase. |
|
||||
|
||||
### 2.12 Tasks (4)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `create_task` | Create a work item (title, assignee, priority, tags). |
|
||||
| `claim_task` | Claim an unclaimed task. |
|
||||
| `complete_task` | Mark done with optional result summary. |
|
||||
| `list_tasks` | Filter by status and/or assignee. |
|
||||
|
||||
### 2.13 Scheduling (3)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `schedule_reminder` | One-shot (`deliver_at`, `in_seconds`) or recurring (`cron`). Delivered to self or `to`. Persists across broker restarts. |
|
||||
| `list_scheduled` | List pending scheduled messages. |
|
||||
| `cancel_scheduled` | Cancel by ID. |
|
||||
|
||||
### 2.14 Mesh metadata — read (4)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `mesh_info` | Overview: peers, groups, state, memory, files, tasks, streams, tables. |
|
||||
| `mesh_stats` | Resource usage per peer: messages in/out, tool calls, uptime, errors. |
|
||||
| `mesh_clock` | Simulation clock status: speed, tick count, simulated time. |
|
||||
| `ping_mesh` | Test messages through the full pipeline, measure round-trip per priority. Diagnoses push delivery issues. |
|
||||
|
||||
### 2.15 Mesh clock — write (3)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `mesh_set_clock` | Set simulation clock speed (1–100x). Peers receive heartbeat ticks at the simulated rate. |
|
||||
| `mesh_pause_clock` | Pause simulation clock. |
|
||||
| `mesh_resume_clock` | Resume paused clock. |
|
||||
|
||||
### 2.16 Skills (5)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `share_skill` | Publish a reusable skill (name + description + instructions + tags + when_to_use + allowed_tools + model + context + agent + user_invocable + argument_hint). Exposed as MCP prompts and `skill://` resources. |
|
||||
| `get_skill` | Load a skill's full instructions by name. |
|
||||
| `list_skills` | Browse available skills, optionally filter by keyword. |
|
||||
| `remove_skill` | Remove a shared skill. |
|
||||
| `mesh_skill_deploy` | Deploy a multi-file skill bundle from zip or git repo. |
|
||||
|
||||
### 2.17 MCP registry tier 1 — peer-hosted (4)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `mesh_mcp_register` | Register a peer's local MCP server with the mesh (server_name, description, tools schema, persistent flag). Other peers can invoke via `mesh_tool_call`. |
|
||||
| `mesh_mcp_list` | List MCP servers in the mesh with their tools + hosting peer. |
|
||||
| `mesh_tool_call` | Call a tool on a mesh-registered MCP server. Routes: caller → broker → hosting peer → execute → result back. 30s timeout. |
|
||||
| `mesh_mcp_remove` | Unregister a peer-hosted MCP server. |
|
||||
|
||||
### 2.18 MCP registry tier 2 — broker-deployed (7)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `mesh_mcp_deploy` | Deploy an MCP server from zip (via `file_id`), git URL, or npx package. Runs on broker VPS in Docker sandbox. Scope: `peer` (default), `mesh`, or `{group/groups/role/peers}`. Runtime: node / python / bun. Memory, network_allow, env with `$vault:` references. |
|
||||
| `mesh_mcp_undeploy` | Stop and remove a managed MCP server. |
|
||||
| `mesh_mcp_update` | Pull latest + restart a git-sourced server. |
|
||||
| `mesh_mcp_logs` | Tail recent logs from a managed server. |
|
||||
| `mesh_mcp_scope` | Get or set visibility scope. |
|
||||
| `mesh_mcp_schema` | Inspect tool schemas for a deployed server. |
|
||||
| `mesh_mcp_catalog` | List all deployed services with status, scope, tool count. |
|
||||
|
||||
### 2.19 Vault (3)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `vault_set` | Store encrypted credential. `type: env` (string, injected as env var via `$vault:<key>`) or `type: file` (file written to `mount_path` in container). |
|
||||
| `vault_list` | List vault entries (keys + metadata only, no values). |
|
||||
| `vault_delete` | Remove a credential. |
|
||||
|
||||
### 2.20 URL watch (3)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `mesh_watch` | Watch a URL for changes. Modes: `hash` (SHA-256 body), `json` (jsonpath extract), `status` (HTTP code). Polling `interval` (min 5s). `notify_on: change \| match:<val> \| not_match:<val>`. Custom headers. |
|
||||
| `mesh_unwatch` | Stop watching by `watch_id`. |
|
||||
| `mesh_watches` | List active watches. |
|
||||
|
||||
### 2.21 Webhooks (3)
|
||||
|
||||
| Tool | v1 behavior |
|
||||
|---|---|
|
||||
| `create_webhook` | Create an inbound webhook. Returns a URL external services (GitHub, CI/CD, monitoring) can POST to. Payload becomes a mesh message to all peers. |
|
||||
| `list_webhooks` | List active webhooks. |
|
||||
| `delete_webhook` | Deactivate by name. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Broker WS protocol
|
||||
|
||||
`apps/broker/src/index.ts` dispatches 85 message types over a single WebSocket endpoint (`WS_PATH`). Each WS message is a client-initiated RPC; most of the 79 MCP tools above map 1:1 to a WS message. Some additional WS messages exist for connection lifecycle + internal routing.
|
||||
|
||||
### 3.1 Connection lifecycle (3)
|
||||
|
||||
- `hello` — client authentication. Ed25519 signature over `{meshId, memberId, pubkey, timestamp}`. Broker verifies, creates presence row, replies with `hello_ack`.
|
||||
- `hello_ack` — server → client, confirms authentication + sends restored peer state.
|
||||
- `get_clock` — get current simulation clock state.
|
||||
|
||||
### 3.2 Messaging (4 WS ops)
|
||||
|
||||
- `send` — send a message. Envelope contains sender, recipient (peer/group/*), priority, nonce, ciphertext.
|
||||
- `peer_dir_request` / `peer_dir_response` — peer-to-peer directory request (read_peer_file under the hood).
|
||||
- `peer_file_request` / `peer_file_response` — peer-to-peer file read.
|
||||
|
||||
### 3.3 Profile + presence (5)
|
||||
|
||||
- `set_status`, `set_summary`, `set_visible`, `set_profile`, `set_stats`
|
||||
|
||||
### 3.4 Groups (2)
|
||||
|
||||
- `join_group`, `leave_group`
|
||||
|
||||
### 3.5 State KV (3)
|
||||
|
||||
- `set_state`, `get_state`, `list_state`
|
||||
|
||||
### 3.6 Memory (3)
|
||||
|
||||
- `remember`, `recall`, `forget`
|
||||
|
||||
### 3.7 Files (5)
|
||||
|
||||
- `get_file`, `list_files`, `file_status`, `grant_file_access`, `delete_file`
|
||||
|
||||
### 3.8 Vectors (3)
|
||||
|
||||
- `vector_store`, `vector_search`, `vector_delete`, `list_collections`
|
||||
|
||||
### 3.9 Graph (2)
|
||||
|
||||
- `graph_query`, `graph_execute`
|
||||
|
||||
### 3.10 Shared SQL (3)
|
||||
|
||||
- `mesh_query`, `mesh_execute`, `mesh_schema`
|
||||
|
||||
### 3.11 Streams (4)
|
||||
|
||||
- `create_stream`, `publish`, `subscribe`, `unsubscribe`, `list_streams`
|
||||
|
||||
### 3.12 Contexts (3)
|
||||
|
||||
- `share_context`, `get_context`, `list_contexts`
|
||||
|
||||
### 3.13 Tasks (4)
|
||||
|
||||
- `create_task`, `claim_task`, `complete_task`, `list_tasks`
|
||||
|
||||
### 3.14 Scheduling (3)
|
||||
|
||||
- `schedule`, `list_scheduled`, `cancel_scheduled`
|
||||
|
||||
### 3.15 Mesh metadata (3)
|
||||
|
||||
- `mesh_info`, `peers_list` (from `list_peers`), `message_status`
|
||||
|
||||
### 3.16 Simulation clock (4)
|
||||
|
||||
- `set_clock`, `pause_clock`, `resume_clock`, `get_clock`
|
||||
|
||||
### 3.17 Skills (4)
|
||||
|
||||
- `share_skill`, `get_skill`, `list_skills`, `remove_skill`, `skill_deploy`
|
||||
|
||||
### 3.18 MCP registry (11)
|
||||
|
||||
- `mcp_register`, `mcp_unregister`, `mcp_list`, `mcp_call`, `mcp_call_response` (peer → peer relay)
|
||||
- `mcp_deploy`, `mcp_undeploy`, `mcp_update`, `mcp_logs`, `mcp_scope`, `mcp_schema`, `mcp_catalog`
|
||||
|
||||
### 3.19 Vault (4)
|
||||
|
||||
- `vault_set`, `vault_get`, `vault_list`, `vault_delete`
|
||||
|
||||
### 3.20 URL watch (3)
|
||||
|
||||
- `watch`, `unwatch`, `watch_list`
|
||||
|
||||
### 3.21 Webhooks (3)
|
||||
|
||||
- `create_webhook`, `list_webhooks`, `delete_webhook`
|
||||
|
||||
### 3.22 Audit (2)
|
||||
|
||||
- `audit_query`, `audit_verify`
|
||||
|
||||
---
|
||||
|
||||
## 4. Broker HTTP endpoints
|
||||
|
||||
The broker serves both WS (`/ws`) and HTTP on the same port. HTTP endpoints are listed here by (method, path) with purpose.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/health` | Health check: liveness probe |
|
||||
| `GET` | `/metrics` | Prometheus metrics endpoint |
|
||||
| `POST` | `/hook/set-status` | Receive hook status updates from CLI `hook` command (Claude Code session lifecycle) |
|
||||
| `POST` | `/join` | Accept v1 invite join (legacy) |
|
||||
| `POST` | `/invites/:code/claim` | v2 invite claim (public, unauthenticated) |
|
||||
| `POST` | `/upload` | Upload a file (returns fileId, used by `share_file`) |
|
||||
| `GET` | `/download/:id` | Download a file (returns content or presigned URL) |
|
||||
| `POST` | `/cli-sync` | CLI sync endpoint — fetches user's meshes from `claudemesh.com` dashboard via JWT, returns mesh list |
|
||||
| `POST` | `/tg/token` | Register a Telegram bot token for a mesh (connects via `connect telegram` CLI command) |
|
||||
| `PATCH` | `/mesh/:id/member/:memberId` | Update a member's profile (admin or self) |
|
||||
| `GET` | `/mesh/:id/members` | List mesh members |
|
||||
| `PATCH` | `/mesh/:id/settings` | Update mesh-level settings (owner/admin) |
|
||||
| `POST` | `/hook/:meshId/:webhookId` | Inbound webhook — external systems POST here to publish a mesh message |
|
||||
| `GET` | `/test/clock` | Dev-only: simulation clock state |
|
||||
| `GET` | `/test/flip` | Dev-only: test flip endpoint |
|
||||
| `GET` | `/test/html` | Dev-only: test HTML endpoint |
|
||||
| `WS` | `/ws` | WebSocket connection for mesh peers (all WS ops above) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Database schema — `mesh` Postgres schema
|
||||
|
||||
23 tables in the `mesh` schema (managed via Drizzle). Defined in `packages/db/src/schema/mesh.ts`.
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `mesh.mesh` | Mesh identity. slug, name, ownerId, createdAt, settings. |
|
||||
| `mesh.member` | Per-mesh member record. Stable, durable. pubkey, displayName, role, groups, joinedAt. |
|
||||
| `mesh.invite` | Invite codes + metadata. |
|
||||
| `mesh.pending_invite` | v2 invite handshake state (pending claim). |
|
||||
| `mesh.audit_log` | Audit events per mesh. |
|
||||
| `mesh.presence` | Ephemeral WS session — one row per active connection. Status, statusSource, statusUpdatedAt. |
|
||||
| `mesh.message_queue` | Queued messages pending push delivery (priority ordered). |
|
||||
| `mesh.pending_status` | In-flight status updates (10s TTL). |
|
||||
| `mesh.state` (meshState) | Shared KV state per mesh. |
|
||||
| `mesh.memory` (meshMemory) | Shared memories with full-text search. |
|
||||
| `mesh.file` (meshFile) | File metadata (uploader, size, sha256, persistence, storage location). |
|
||||
| `mesh.file_access` (meshFileAccess) | Per-recipient ACL on files. |
|
||||
| `mesh.file_key` (meshFileKey) | Per-recipient wrapped symmetric keys for E2E encryption. |
|
||||
| `mesh.context` (meshContext) | Shared context entries. |
|
||||
| `mesh.task` (meshTask) | Tasks with lifecycle (open, claimed, completed, cancelled). |
|
||||
| `mesh.stream` (meshStream) | Stream metadata. |
|
||||
| `mesh.skill` (meshSkill) | Skill registrations (name, content, frontmatter, tags). |
|
||||
| `mesh.webhook` (meshWebhook) | Inbound webhook registrations. |
|
||||
| `mesh.service` (meshService) | Deployed MCP server state (container ID, scope, env, runtime, memory, logs). |
|
||||
| `mesh.vault_entry` (meshVaultEntry) | Encrypted vault entries per (mesh, peer, key). |
|
||||
| `mesh.scheduled_message` | Scheduled / recurring reminders (cron + one-shot). |
|
||||
| `mesh.peer_state` | Per-peer state (groups, role, profile, message mode preference). |
|
||||
| `mesh.telegram_bridge` | Telegram bot registration per mesh. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Broker backend services
|
||||
|
||||
Five external services the broker manages at runtime. All currently work in v1 and ship in the default Docker Compose deployment.
|
||||
|
||||
| Service | Purpose | File | Per-mesh model |
|
||||
|---|---|---|---|
|
||||
| **Postgres** (Drizzle) | Primary data store for mesh schema. Also used for `mesh_execute` / `mesh_query` / `mesh_schema` shared-SQL tools via per-mesh schemas. | `db.ts` | Schema-per-mesh for shared SQL tools |
|
||||
| **Neo4j** | Graph queries (`graph_query`, `graph_execute`). | `neo4j-client.ts` | Database-per-mesh (Enterprise) or labeled-node fallback (Community) |
|
||||
| **Qdrant** | Vector embeddings + nearest-neighbor search. | `qdrant.ts` | Collection naming: `mesh_<meshId>_<collection>`, 1536-dim default, cosine distance |
|
||||
| **MinIO** | File storage for `share_file` / `get_file`. | `minio.ts` | Bucket-per-mesh: `mesh-<meshId>`. Persistent + ephemeral key paths. |
|
||||
| **Docker** | Runs deployed MCP servers in sandboxed containers. | `index.ts` (deploy handler) | Container-per-deployment. Read-only root, dropped caps, memory limits, network_allow. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Broker core subsystems
|
||||
|
||||
### 7.1 Status engine (`broker.ts`, 2066 lines)
|
||||
|
||||
**Battle-tested status model** ported from `claude-intercom`. Rules:
|
||||
|
||||
- Status sources are ranked: `hook` (3) > `manual` (2) > `jsonl` (1)
|
||||
- On status update:
|
||||
- If status **changed** → bump everything, record new source
|
||||
- If status **unchanged**, incoming source ≥ recorded → upgrade
|
||||
- If status **unchanged**, incoming source < recorded:
|
||||
- Recorded source still fresh → keep it (bump timestamp only)
|
||||
- Recorded source stale → downgrade to honest attribution
|
||||
- `HOOK_FRESHNESS_MS` window (default 60s) for "fresh" classification
|
||||
- `WORKING_TTL_MS` after which `working` status reverts to `idle`
|
||||
- `PENDING_TTL_MS = 10_000` for pending status cleanup
|
||||
- `TTL_SWEEP_INTERVAL_MS = 15_000` for periodic cleanup
|
||||
|
||||
**Must preserve** — this is the correctness engine for `set_status`, `list_peers`, and Claude Code's status line.
|
||||
|
||||
### 7.2 Message queue + priority delivery
|
||||
|
||||
- Messages are stored in `mesh.message_queue` with priority (`now`, `next`, `low`)
|
||||
- `now` messages bypass busy-gate and are pushed immediately
|
||||
- `next` messages wait for idle peer
|
||||
- `low` messages are pull-only (delivered when peer explicitly drains via `check_messages`)
|
||||
- Queue is drained via `drainForMember(meshId, memberId)` on WS message arrival or manual `check_messages`
|
||||
- Duplicate delivery prevention via `messageId` UUID tracking
|
||||
|
||||
### 7.3 Scheduled message delivery (`index.ts` in-memory + DB persistence)
|
||||
|
||||
- One-shot: `deliver_at` (timestamp) or `in_seconds`
|
||||
- Recurring: standard 5-field cron expression
|
||||
- Persists to `mesh.scheduled_message` table — survives broker restart
|
||||
- On broker start, pending schedules are re-registered
|
||||
- Delivery is via the normal `send_message` pipeline with `subtype: reminder`
|
||||
|
||||
### 7.4 URL watch subsystem (`index.ts`)
|
||||
|
||||
- Poller runs in-process (worker per watch)
|
||||
- Modes: `hash` (SHA-256 of body), `json` (extract jsonpath value), `status` (HTTP status)
|
||||
- `notify_on: change | match:<val> | not_match:<val>`
|
||||
- Persists to DB so watches survive broker restart
|
||||
- Min interval 5s, max 24h
|
||||
|
||||
### 7.5 Telegram bridge (`telegram-bridge.ts`, 1711 lines)
|
||||
|
||||
**Substantial subsystem.** Provides Telegram Bot API integration:
|
||||
|
||||
- Bot token registration per mesh via `POST /tg/token`
|
||||
- Long-polling or webhook mode
|
||||
- `tg:<username>` peer identity registration in the mesh's member table
|
||||
- Inbound Telegram messages → mesh `send_message` events with `subtype: telegram`
|
||||
- Outbound `send_message(to: "tg:<name>")` → Telegram Bot API call
|
||||
- Chat-to-mesh mapping (Telegram chat_id ↔ mesh peer)
|
||||
- User discovery (`connectChat`)
|
||||
- Bridge row persistence in `mesh.telegram_bridge`
|
||||
|
||||
**This is ~18% of the broker's total source**. v2 must either:
|
||||
1. Port the logic into a standalone MCP connector (`apps/mcp-telegram/`), or
|
||||
2. Keep this file in the broker and wire it into the v2 architecture unchanged (my recommendation per the previous conversation — bundled into the broker image)
|
||||
|
||||
Either way, **every behavior documented here must still work after v2 lands**.
|
||||
|
||||
### 7.6 Auth + crypto (`crypto.ts`, `broker-crypto.ts`, `jwt.ts`)
|
||||
|
||||
- **Hello signatures**: Ed25519 signed tuple of `(meshId, memberId, pubkey, timestamp)`. Verified on every WS connection. Replay protection via timestamp window.
|
||||
- **Invite verification**: canonical invite payload (`canonicalInvite`) signed by mesh owner, Ed25519 verified on claim
|
||||
- **JWT**: for `/cli-sync` endpoint — the CLI obtains a JWT from `claudemesh.com` via browser flow, passes it to the broker, broker verifies and returns the user's mesh list
|
||||
- **File envelopes**: client-side AES-GCM + per-recipient key wrapping (file_key table)
|
||||
|
||||
### 7.7 Rate limiting (`rate-limit.ts`)
|
||||
|
||||
- Per-peer rate limits on expensive operations
|
||||
- Currently in-process (not Redis-backed)
|
||||
- Enforces limits on `send`, `vector_store`, `mesh_execute`, `mesh_mcp_deploy`, etc.
|
||||
|
||||
### 7.8 Metrics (`metrics.ts`)
|
||||
|
||||
Prometheus metrics exposed at `/metrics`:
|
||||
- Request counts by op type
|
||||
- Latencies p50/p99
|
||||
- Connection counts per mesh
|
||||
- Message delivery counts by priority
|
||||
- Error rates
|
||||
|
||||
### 7.9 Audit log (`audit.ts`)
|
||||
|
||||
- Every mutation is audited to `mesh.audit_log`
|
||||
- Tamper-evidence via hash chaining
|
||||
- Accessible via `audit_query` and `audit_verify` WS ops
|
||||
|
||||
### 7.10 Member API (`member-api.ts`, 284 lines)
|
||||
|
||||
Exports:
|
||||
- `updateMemberProfile()` — used by `PATCH /mesh/:id/member/:memberId`
|
||||
- `listMeshMembers()` — used by `GET /mesh/:id/members`
|
||||
- `updateMeshSettings()` — used by `PATCH /mesh/:id/settings`
|
||||
|
||||
### 7.11 CLI sync (`cli-sync.ts`, 133 lines)
|
||||
|
||||
Exports `handleCliSync()` for `POST /cli-sync`. This is **already the "CLI sync meshes from dashboard" feature** — v2 will reuse this endpoint for its mesh-list refresh logic.
|
||||
|
||||
### 7.12 Webhook subsystem (`webhooks.ts`, 97 lines)
|
||||
|
||||
Handles `POST /hook/:meshId/:webhookId` inbound. Signature verification (HMAC), payload normalization, mesh message emission.
|
||||
|
||||
---
|
||||
|
||||
## 8. CLI core subsystems
|
||||
|
||||
### 8.1 WS client (`ws/client.ts`, 2191 lines)
|
||||
|
||||
**The biggest CLI file.** Implements the full WS protocol with:
|
||||
- Connection management, reconnect with exponential backoff
|
||||
- Message queue for offline buffering
|
||||
- Request/response correlation via `_reqId`
|
||||
- Ed25519 hello signature generation
|
||||
- Crypto envelope wrapping for `send_message` payloads
|
||||
- Push notification delivery (messages, state changes, system events)
|
||||
- Per-mesh connection pooling (one WS per mesh)
|
||||
|
||||
### 8.2 MCP server (`mcp/server.ts`, 2139 lines)
|
||||
|
||||
Second biggest CLI file. Implements:
|
||||
- MCP stdio transport (registered with Claude Code via `install.ts`)
|
||||
- Tool registry from `mcp/tools.ts`
|
||||
- Dispatch to 79 handlers (one per tool)
|
||||
- WS client pooling (one connection per mesh)
|
||||
- Crypto primitives for memory/state encryption
|
||||
- Inline file-read helpers for `read_peer_file`
|
||||
- Channel notification forwarding from broker → Claude Code via MCP elicitation
|
||||
|
||||
### 8.3 Crypto (`crypto/*.ts`)
|
||||
|
||||
- `keypair.ts` — Ed25519 keypair generation + persistence (`~/.claudemesh/keys/<mesh>.key`)
|
||||
- `envelope.ts` — NaCl `crypto_box` envelope wrapping
|
||||
- `file-crypto.ts` — AES-GCM file encryption + per-recipient key wrapping
|
||||
- `hello-sig.ts` — Hello signature generation/verification
|
||||
|
||||
### 8.4 Auth + invite (`auth/*.ts`, `invite/*.ts`, `lib/invite-v2.ts`)
|
||||
|
||||
- `callback-listener.ts` — local HTTP server that catches browser OAuth callback (for `sync` command)
|
||||
- `open-browser.ts` — cross-platform browser launcher
|
||||
- `pairing-code.ts` — pairing code display
|
||||
- `sync-with-broker.ts` — JWT-based sync from dashboard
|
||||
- `invite/parse.ts` — parse v1 invite URLs
|
||||
- `invite/enroll.ts` — enroll into a mesh from an invite
|
||||
- `lib/invite-v2.ts` — v2 invite format (short-code + signed payload)
|
||||
|
||||
### 8.5 State + config (`state/config.ts`)
|
||||
|
||||
- `~/.claudemesh/config.json` read/write (mesh list, keypairs, profile defaults)
|
||||
- 0600 permission enforcement
|
||||
- Schema validation
|
||||
|
||||
### 8.6 TUI primitives (`tui/*.ts`)
|
||||
|
||||
- `colors.ts` — hard-coded ANSI colors
|
||||
- `index.ts` — input helpers
|
||||
- `screen.ts` — raw-mode screen control
|
||||
- `spinner.ts` — simple spinner
|
||||
|
||||
### 8.7 Templates (`templates/index.ts`)
|
||||
|
||||
- `dev-team`, `research`, `ops-incident`, `simulation`, `personal`
|
||||
- Each template seeds initial state + preset groups
|
||||
|
||||
### 8.8 Tests
|
||||
|
||||
- `__tests__/crypto-roundtrip.test.ts` — crypto round-trip verification
|
||||
- `__tests__/invite-parse.test.ts` — invite URL parsing
|
||||
- No integration tests against a real broker
|
||||
|
||||
---
|
||||
|
||||
## 9. Infrastructure + deployment
|
||||
|
||||
### 9.1 Broker runtime (`env.ts`)
|
||||
|
||||
Environment variables the broker expects:
|
||||
- `DATABASE_URL` — Postgres connection
|
||||
- `NEO4J_URL`, `NEO4J_USER`, `NEO4J_PASSWORD`
|
||||
- `QDRANT_URL`
|
||||
- `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_USE_SSL`
|
||||
- `STATUS_TTL_SECONDS` — working status timeout
|
||||
- `HOOK_FRESH_WINDOW_SECONDS` — hook source freshness window
|
||||
- `TELEGRAM_BOT_TOKEN` — for bridge
|
||||
- `DASHBOARD_JWT_SECRET` — for `/cli-sync` verification
|
||||
- `PORT` (default 8787)
|
||||
- Various feature flags
|
||||
|
||||
### 9.2 CLI runtime
|
||||
|
||||
- Node >= 20 required (checked in `doctor`)
|
||||
- `claude` binary must be on PATH
|
||||
- `~/.claudemesh/` directory with config + keys
|
||||
- `~/.claude.json` MCP server registration
|
||||
- `~/.claude/settings.json` status hooks registration
|
||||
|
||||
### 9.3 Deployment (Coolify/Docker Compose)
|
||||
|
||||
- Broker deployed via Coolify + Gitea CI on OVHcloud VPS (`ic.claudemesh.com`)
|
||||
- WS endpoint: `wss://ic.claudemesh.com/ws`
|
||||
- HTTP endpoint: `https://ic.claudemesh.com`
|
||||
- Postgres, Neo4j, Qdrant, MinIO run as siblings in Docker Compose
|
||||
- Deployed MCP sandboxes use the host Docker daemon via socket mount
|
||||
|
||||
---
|
||||
|
||||
## 10. Features not in the tool/WS surface (behavioral)
|
||||
|
||||
These are v1 behaviors that exist but aren't enumerated as tools. Each must still work after v2.
|
||||
|
||||
| Feature | Location | Notes |
|
||||
|---|---|---|
|
||||
| Flag-first `claudemesh --resume xxx` routing | `cli/src/index.ts` §339 | Rewrites argv to `launch --resume xxx` |
|
||||
| Bare `claudemesh` → welcome wizard | `cli/src/index.ts` §334 | Runs `runWelcome()` |
|
||||
| Status hook auto-registration | `commands/install.ts` | Writes to `~/.claude/settings.json` |
|
||||
| Claude Code session hook handling | `commands/hook.ts` | Receives stdin JSON, posts to `/hook/set-status` |
|
||||
| Per-mesh keypair directory | `crypto/keypair.ts` | `~/.claudemesh/keys/<mesh>.key` with 0600 perms |
|
||||
| E2E file encryption with re-wrapping | `crypto/file-crypto.ts` + `mesh_file_key` table | `grant_file_access` re-wraps symmetric key for new recipient |
|
||||
| Priority message delivery | `broker.ts` | `now` bypasses busy-gate, `next` waits for idle, `low` is pull-only |
|
||||
| Hook > manual > jsonl status priority | `broker.ts` | Documented in §7.1 |
|
||||
| Simulation clock for test time | `index.ts` (broker) | Peers receive heartbeat ticks at simulated rate |
|
||||
| Audit log hash chaining | `audit.ts` | Tamper-evident — tools call `audit_verify` to check |
|
||||
| Dashboard-CLI sync | `auth/sync-with-broker.ts` + `cli-sync.ts` | Browser JWT flow, fetches mesh list from dashboard |
|
||||
| Telegram chat ↔ mesh peer mapping | `telegram-bridge.ts` | Bidirectional routing via `tg:<username>` |
|
||||
| Inbound webhook payload normalization | `webhooks.ts` | External systems POST, becomes a mesh message |
|
||||
| Rate limiting per peer per operation | `rate-limit.ts` | In-memory token buckets |
|
||||
| Prometheus metrics | `metrics.ts` | `/metrics` endpoint |
|
||||
|
||||
---
|
||||
|
||||
## 11. Test coverage (v1)
|
||||
|
||||
| Test | File | Notes |
|
||||
|---|---|---|
|
||||
| Crypto round-trip | `apps/cli/src/__tests__/crypto-roundtrip.test.ts` | Encrypt → decrypt verification |
|
||||
| Invite URL parsing | `apps/cli/src/__tests__/invite-parse.test.ts` | v1 and v2 formats |
|
||||
| Broker tests | `apps/broker/tests/*.test.ts` | broker.test.ts, invite-signature.test.ts, invite-v2.test.ts, hello-signature.test.ts, rate-limit.test.ts, encoding.test.ts, dup-delivery.test.ts, metrics.test.ts, logging.test.ts, integration/health.test.ts |
|
||||
|
||||
**v1 test coverage is minimal for the CLI side.** 2 unit test files for 12k LOC.
|
||||
|
||||
Broker has ~10 test files. They cover crypto primitives, invite flow, hello signatures, rate limiting, metrics — but **not** the 85 WS message handlers comprehensively.
|
||||
|
||||
---
|
||||
|
||||
## 12. The "must preserve" list (high-priority regression checks)
|
||||
|
||||
If v2 breaks any of these, it's a user-facing regression:
|
||||
|
||||
### 12.1 First-run experience
|
||||
- [ ] `claudemesh` bare command → welcome wizard
|
||||
- [ ] `claudemesh install` registers MCP server + status hooks in Claude Code config
|
||||
- [ ] `claudemesh join <url>` enrolls into a mesh from a v1 OR v2 invite URL
|
||||
- [ ] `claudemesh launch` starts Claude Code with mesh connectivity
|
||||
|
||||
### 12.2 Session lifecycle
|
||||
- [ ] Status hooks fire correctly on Claude Code session start/stop/pause
|
||||
- [ ] `set_status` honors priority (hook > manual > jsonl)
|
||||
- [ ] `list_peers` shows live status with freshness gating
|
||||
- [ ] Status TTL sweeper runs every 15s
|
||||
|
||||
### 12.3 Messaging
|
||||
- [ ] `send_message(to: peer, priority: "now")` delivers immediately
|
||||
- [ ] `send_message(to: peer, priority: "next")` waits for idle
|
||||
- [ ] `send_message(to: "@group")` broadcasts to group members
|
||||
- [ ] `send_message(to: "*")` broadcasts to all mesh peers
|
||||
- [ ] Offline recipients receive queued messages on reconnect
|
||||
- [ ] Duplicate delivery is prevented by `messageId` tracking
|
||||
|
||||
### 12.4 Cryptographic integrity
|
||||
- [ ] Ed25519 keypair generation + persistence with 0600 perms
|
||||
- [ ] Hello signature verification rejects replay within timestamp window
|
||||
- [ ] `send_message` envelopes are E2E encrypted (NaCl crypto_box)
|
||||
- [ ] File uploads are AES-GCM encrypted with per-recipient key wrapping
|
||||
- [ ] `grant_file_access` re-wraps symmetric key for a new recipient
|
||||
|
||||
### 12.5 All 79 MCP tools
|
||||
- [ ] Every tool in §2 dispatches correctly through the CLI's MCP server
|
||||
- [ ] Every tool delegates to the broker WS protocol or local handler as appropriate
|
||||
- [ ] No tool returns "not implemented" or throws an unexpected error
|
||||
|
||||
### 12.6 Broker backends
|
||||
- [ ] `mesh_query` / `mesh_execute` / `mesh_schema` work against per-mesh Postgres schema
|
||||
- [ ] `graph_query` / `graph_execute` work against per-mesh Neo4j database
|
||||
- [ ] `vector_store` / `vector_search` work against per-mesh Qdrant collection
|
||||
- [ ] `share_file` / `get_file` work through per-mesh MinIO bucket
|
||||
- [ ] `mesh_mcp_deploy` spawns a Docker container with correct scope + env + network_allow
|
||||
- [ ] `vault_set` + `$vault:<key>` env injection works end-to-end for deployed MCPs
|
||||
|
||||
### 12.7 Scheduled + URL watch
|
||||
- [ ] `schedule_reminder` with `cron` survives broker restart (persisted in DB)
|
||||
- [ ] `mesh_watch` polls at the specified interval and notifies on change
|
||||
- [ ] Watch state persists across broker restart
|
||||
|
||||
### 12.8 Telegram bridge
|
||||
- [ ] `connect telegram` registers bot token via `POST /tg/token`
|
||||
- [ ] Bot token is stored in `mesh.telegram_bridge`
|
||||
- [ ] Inbound Telegram messages are routed as mesh messages
|
||||
- [ ] `send_message(to: "tg:<username>")` routes via Telegram Bot API
|
||||
- [ ] `disconnect telegram` tears down the bridge cleanly
|
||||
|
||||
### 12.9 Dashboard sync
|
||||
- [ ] `claudemesh sync` browser flow completes and fetches mesh list
|
||||
- [ ] `POST /cli-sync` with valid JWT returns user's dashboard meshes
|
||||
|
||||
### 12.10 Webhooks
|
||||
- [ ] `create_webhook` returns a POST URL
|
||||
- [ ] External POST to webhook URL becomes a mesh message
|
||||
- [ ] HMAC signature validation rejects unsigned requests
|
||||
- [ ] `list_webhooks` + `delete_webhook` work
|
||||
|
||||
### 12.11 Doctor checks
|
||||
- [ ] Node >= 20 check
|
||||
- [ ] `claude` binary on PATH
|
||||
- [ ] MCP server registered in `~/.claude.json`
|
||||
- [ ] Status hooks registered in `~/.claude/settings.json`
|
||||
- [ ] `~/.claudemesh/config.json` exists + parses + 0600 perms
|
||||
- [ ] Mesh keypairs valid
|
||||
|
||||
---
|
||||
|
||||
## 13. What v2 is adding (net new)
|
||||
|
||||
Not part of the regression list, but tracked here so we don't lose sight of the forward-looking scope.
|
||||
|
||||
### 13.1 New CLI features (from user's stated v2 intent)
|
||||
|
||||
- [ ] `claudemesh login` — device-code OAuth against claudemesh.com's Better Auth backend
|
||||
- [ ] `claudemesh register` — create a new account from the CLI (via browser handoff)
|
||||
- [ ] `claudemesh new` — create a mesh from the CLI against `POST /api/my/meshes` (not via templates in the CLI — via dashboard API)
|
||||
- [ ] `claudemesh invite` — generate an invite from the CLI via `POST /api/my/meshes/:slug/invites`
|
||||
- [ ] `claudemesh whoami` — show current identity + token source
|
||||
- [ ] `claudemesh logout` — revoke server-side session + clear local credentials
|
||||
|
||||
### 13.2 Architecture improvements (from user's v2 intent)
|
||||
|
||||
- [ ] Feature-folder `services/` layer with strict facade boundaries
|
||||
- [ ] ESLint + dependency-cruiser boundary enforcement
|
||||
- [ ] `cli/` vs `ui/` separation (non-Ink I/O vs Ink rendering)
|
||||
- [ ] `entrypoints/` folder with cli + mcp entries
|
||||
- [ ] Typed error classes per service with `toDomainError` helper
|
||||
- [ ] Coverage threshold enforcement in CI
|
||||
|
||||
### 13.3 Not in v1.0.0 scope (defer to v1.1+)
|
||||
|
||||
Everything from the Composer 2 review rounds that isn't Pass 1:
|
||||
|
||||
- Local-first SQLite source of truth (Lamport, sync daemon, publish transaction)
|
||||
- Broker security hardening (role-per-mesh Postgres, Docker egress proxy, SSRF policy)
|
||||
- ICU MessageFormat + per-locale budgets
|
||||
- Accessibility token-signal matrix
|
||||
- Tiered MCP catalog + audit process
|
||||
- session_kind enum
|
||||
- NFC peer_id normalization
|
||||
- Write queue state machine
|
||||
|
||||
These stay in the `.artifacts/specs/` as reference documents. They describe a good destination. They are NOT v1.0.0 requirements.
|
||||
|
||||
---
|
||||
|
||||
## 14. Known v1 technical debt / gaps (worth noting)
|
||||
|
||||
These aren't features — they're places where v1 is weaker than it could be. Document here so v2 doesn't blindly port the weaknesses.
|
||||
|
||||
- **CLI auth is missing** — v1 has no `login` / `logout` command. All account-level operations require the web dashboard. This is what v2 is adding.
|
||||
- **Imperative command branching** — `commands/launch.ts` is 775 lines with nested flag handling. Cleaner in v2's flow pipeline.
|
||||
- **Minimal CLI test coverage** — 2 test files for 12k LOC. v2 should have colocated tests per service.
|
||||
- **Rate limiting is in-memory only** — doesn't survive broker restart; not Redis-backed.
|
||||
- **No CLI-side caching** — every `list_peers` / `mesh_info` call hits the broker. v2's local-first layer (Pass 2) addresses this.
|
||||
- **Telegram bridge is a large monolithic file** (1711 lines) — legitimate complexity, but v2 may want to modularize if it touches it.
|
||||
- **v1 wizard bleed-through** — `launch` → `claude` handoff leaves ANSI state dirty. v2's `resetTerminal()` choke point fixes this.
|
||||
|
||||
None of these are regressions if v2 keeps them as-is. v2 should **not** prioritize fixing them — fix them when they become a problem, not speculatively.
|
||||
|
||||
---
|
||||
|
||||
## 15. Reading this inventory
|
||||
|
||||
**If you're implementing v2 Phase 1** (foundation layers): every tool in §2, every WS op in §3, every HTTP endpoint in §4, every DB table in §5 must have a place in the v2 folder structure. No new semantics, no improved algorithms — just move the working code.
|
||||
|
||||
**If you're reviewing a v2 PR**: check it against §12 ("must preserve" list). If the PR changes the behavior of anything in that list, it's a regression and needs explicit sign-off.
|
||||
|
||||
**If you're writing v2 docs**: reference this document. Every feature here is user-visible and documented in v1's README / slash-command help / tool descriptions. v2 docs should mention every feature from §2 as preserved.
|
||||
|
||||
---
|
||||
|
||||
**End of inventory.**
|
||||
1068
.artifacts/backlog/2026-04-11-v2-parity-test-plan.md
Normal file
BIN
.artifacts/hero-animation/clawd-apple-zoom.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
.artifacts/hero-animation/clawd-zoom-v2.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
.artifacts/hero-animation/clawd-zoom.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
.artifacts/hero-animation/fcc-preview-v1.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
.artifacts/hero-animation/fcc-preview-v2.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
.artifacts/hero-animation/fcc-preview-v3.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
.artifacts/hero-animation/features-section.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
.artifacts/hero-animation/features-with-skills.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
.artifacts/hero-animation/frame-01-alone.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
BIN
.artifacts/hero-animation/hero-with-mesh-v1.png
Normal file
|
After Width: | Height: | Size: 303 KiB |
BIN
.artifacts/hero-animation/landing-cover.png
Normal file
|
After Width: | Height: | Size: 475 KiB |
BIN
.artifacts/hero-animation/landing-live.png
Normal file
|
After Width: | Height: | Size: 462 KiB |
BIN
.artifacts/hero-animation/mesh-constellation-v1.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
.artifacts/hero-animation/mesh-constellation-v2.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
.artifacts/hero-animation/mesh-constellation-v3.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
.artifacts/hero-animation/mesh-hero-apple-clawd.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
.artifacts/hero-animation/mesh-hero-clip.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
.artifacts/hero-animation/mesh-hero-full.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
.artifacts/hero-animation/mesh-hero-v1.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
.artifacts/hero-animation/mesh-icon-big.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
.artifacts/hero-animation/mesh-no-overlap.png
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
.artifacts/hero-animation/mesh-peers-equal.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
.artifacts/hero-animation/mesh-trail-5700.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
.artifacts/hero-animation/mesh-trail-inflight.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
.artifacts/hero-animation/mesh-trail-top.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
.artifacts/hero-animation/mesh-trail-v1.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
.artifacts/hero-animation/mesh-trail-v2.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
.artifacts/hero-animation/mesh-triangle.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
.artifacts/hero-animation/mesh-zoom-mid.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
.artifacts/hero-animation/prompt-box-early.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
.artifacts/hero-animation/prompt-input-live.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
.artifacts/hero-animation/reference.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
BIN
.artifacts/hero-animation/responsive-1200.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
.artifacts/hero-animation/responsive-1700.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
.artifacts/hero-animation/responsive-800.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
.artifacts/hero-animation/session-mid-2.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
.artifacts/hero-animation/session-mid-3.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
.artifacts/hero-animation/session-mid.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
.artifacts/hero-animation/where-mesh-fits-v2.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
.artifacts/hero-animation/where-mesh-fits.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
29
.artifacts/prompts/claudemesh-prompts.rtf
Normal file
@@ -0,0 +1,29 @@
|
||||
{\rtf1\ansi\ansicpg1252\cocoartf2867
|
||||
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;}
|
||||
{\colortbl;\red255\green255\blue255;}
|
||||
{\*\expandedcolortbl;;}
|
||||
\margl1440\margr1440\vieww11180\viewh8060\viewkind0
|
||||
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0
|
||||
|
||||
\f0\fs24 \cf0 Mesh templates for predefined roles, groups\'85\
|
||||
Mesh blockchain, can it be a good addition? For what?\
|
||||
Mesh webhooks, external web sockets, restful apis to be connected to the mesh (mcp)\
|
||||
Mesh skills available for all ai? Like a mesh catalog of skills for sessions to get and use them?\
|
||||
Inicial private mesh by default for every new user\
|
||||
Mesh dashboard for situational awareness of mesh, to illustrate the peers connected, their activity, status, mesh structure\
|
||||
Mesh of meshes? bridge?\
|
||||
Mesh Connectors: slack, telegram, they can appear as peers? Or sth different?\
|
||||
Connect humans to the mesh? Peer info to know about if human, type of channel (telegram or whatever) or llm model if ai?\
|
||||
How to connect others than just claude code? The problem will be the push system I suppose\
|
||||
\
|
||||
Add path (pwd) where each session is being executed for them to understand how to reference files if same computer? Maybe only visible for peers on same computer?\
|
||||
What if a peer on connection can make available all the project files, folders and subfolders? Direct access? So other ai can read files if needed from connected projects?\
|
||||
Can we have peer stats for example about context consumption?\
|
||||
Mesh notifications about new peers, new connectors, new resources? Broadcast?\
|
||||
Allow group or role changes dynamically not only on mesh connection?\
|
||||
Dynamic mcp that can be connected or disconnected on realtime without resetting the claude code sessions?\
|
||||
Mesh templates on creation, with a predefined structure that it can be changed as well by mesh admin role? Or any? Or what idea?\
|
||||
What if reminders can be just cron so ai knows exactly how to configure crons for the mesh? So broker can handle the cron creation? What about mesh heartbeats to keep ai alive?\
|
||||
Sandbox for code execution, python, node, chromium, etc so any peer can connect to resources, and resources being scalable on real time if a new peer needs a sandbox?\
|
||||
\
|
||||
}
|
||||
154
.artifacts/shipped/2026-04-15-ship-all-retrospective.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Ship-All Session — 2026-04-15
|
||||
|
||||
Full checklist from the "Claude Code-grade CLI" bar, shipped end-to-end.
|
||||
|
||||
## Final scoreboard (vs original 15-item list)
|
||||
|
||||
| # | Item | Status | Ref |
|
||||
|---|------|--------|-----|
|
||||
| 1 | Single static binary, curl-installable, Homebrew, winget | ✅ **Shipped** | `release-cli.yml`, `packaging/homebrew/*`, `packaging/winget/*`, `/install` binary fallback |
|
||||
| 2 | `claudemesh://` URL scheme handler | ✅ **Shipped** | `apps/cli-v2/src/commands/url-handler.ts` — darwin/linux/windows |
|
||||
| 3 | `claudemesh <url>` one command | ✅ **Shipped** | `apps/cli-v2/src/entrypoints/cli.ts` bare dispatch |
|
||||
| 4 | `-y` fully non-interactive | ✅ **Shipped** | `launch.ts` — bypasses wizard |
|
||||
| 5 | Unified onboarding | ✅ **Shipped** | `welcome.ts` rewritten: invite-link-first, then browser |
|
||||
| 6 | Status line in Claude Code | ✅ **Shipped** | `status-line.ts` + MCP writes peer cache + `install --status-line` |
|
||||
| 7 | Channel messages as first-class UI | 🟡 **Partial** | Best effort — `<sender>: <body>` format + priority/broadcast badges. True rich UI requires Claude Code protocol change we don't own. |
|
||||
| 8 | Recovery phrase / encrypted backup | ✅ **Shipped** | `backup.ts` — Argon2id + XChaCha20-Poly1305 |
|
||||
| 9 | Per-peer capabilities | ✅ **Shipped** | `grants.ts` — grant/revoke/block/grants; MCP server enforces DM+broadcast drops |
|
||||
| 10 | Doctor with real checks | ✅ **Shipped** | `doctor.ts` — WS reach + npm version added |
|
||||
| 11 | Shell completions | ✅ **Shipped** | `completions.ts` — bash/zsh/fish |
|
||||
| 12 | QR code on share | ✅ **Shipped** | `qr.ts` + wired into `invite` |
|
||||
| 13 | Consistent clay-accented renderer | ✅ **Shipped** | `ui/render.ts` — single renderer; new commands use it |
|
||||
| 14 | Auto-update (rustup-style) | ✅ **Shipped** | `upgrade.ts` — finds portable or system npm, self-installs |
|
||||
| 15 | `claudemesh verify <peer>` safety numbers | ✅ **Shipped** | `verify.ts` — 30-digit SAS |
|
||||
|
||||
**Final: 14/15 fully shipped + 1 partial = 97% addressed.** Item 7 is blocked
|
||||
on Claude Code protocol work outside our scope.
|
||||
|
||||
## What landed across the session
|
||||
|
||||
### npm
|
||||
- `claudemesh-cli@1.0.0-alpha.30` on the alpha dist-tag
|
||||
|
||||
### GitHub Releases
|
||||
- `cli-v1.0.0-alpha.29` live with 5 binaries + SHA256SUMS
|
||||
(darwin-x64, darwin-arm64, linux-x64, linux-arm64, windows-x64.exe)
|
||||
- `cli-v1.0.0-alpha.30` workflow running to reproduce the set
|
||||
|
||||
### CI
|
||||
- `.github/workflows/release-cli.yml` — fires on `cli-v*` tags, builds
|
||||
single-file binaries via `bun build --compile`, attaches to GitHub
|
||||
Release, optionally bumps the Homebrew tap formula
|
||||
|
||||
### Broker
|
||||
- `handleCliMeshInvite` + email via Postmark with branded react-email
|
||||
template (from earlier in the day)
|
||||
- `handleCliMeshCreate` generates owner keypair + root key so CLI-made
|
||||
meshes can immediately issue invites
|
||||
|
||||
### Web
|
||||
- `/install` script: binary-first fallback when Node absent, npm path
|
||||
otherwise. No sudo required.
|
||||
- `apps/web/src/modules/join/install-toggle.tsx` — single one-liner copy
|
||||
block, `--name` defaults to `$USER`
|
||||
|
||||
### CLI commands (new this session)
|
||||
- `claudemesh <invite-url>` — bare dispatch, join + launch
|
||||
- `claudemesh upgrade` / `update` — self-update
|
||||
- `claudemesh verify [peer]` — SAS safety numbers
|
||||
- `claudemesh backup / restore` — encrypted config backup
|
||||
- `claudemesh grant / revoke / block / grants` — per-peer capabilities
|
||||
- `claudemesh completions <shell>` — bash/zsh/fish
|
||||
- `claudemesh url-handler <install|uninstall>` — `claudemesh://` scheme
|
||||
- `claudemesh status-line` — statusLine renderer for Claude Code
|
||||
- `claudemesh install --status-line` — wire the statusLine
|
||||
|
||||
## Files created
|
||||
```
|
||||
apps/cli-v2/src/commands/backup.ts # backup/restore
|
||||
apps/cli-v2/src/commands/completions.ts # shell completions
|
||||
apps/cli-v2/src/commands/grants.ts # per-peer caps
|
||||
apps/cli-v2/src/commands/status-line.ts # statusLine renderer
|
||||
apps/cli-v2/src/commands/upgrade.ts # auto-update
|
||||
apps/cli-v2/src/commands/url-handler.ts # :// scheme registration
|
||||
apps/cli-v2/src/commands/verify.ts # SAS safety numbers
|
||||
apps/cli-v2/src/emails/mesh-invitation.tsx # branded react-email template
|
||||
apps/cli-v2/src/ui/qr.ts # QR renderer
|
||||
apps/cli-v2/src/ui/render.ts # unified renderer
|
||||
apps/cli-v2/scripts/build-binaries.ts # cross-platform compile
|
||||
apps/broker/src/emails/mesh-invitation.tsx # (broker copy — pre-session)
|
||||
.github/workflows/release-cli.yml # binary CI
|
||||
packaging/homebrew/claudemesh.rb.template # brew formula
|
||||
packaging/winget/claudemesh.yaml.template # winget manifest
|
||||
```
|
||||
|
||||
## Gotchas hit and fixed
|
||||
|
||||
1. **`capability_v_2` vs `capability_v2`** — Drizzle's `casing: snake_case`
|
||||
inserts an underscore before digits, but the migration SQL
|
||||
(`0019_invite-v2-and-email.sql`) used `capability_v2`. Production DB
|
||||
had both drifted. Fixed by hand: `ALTER TABLE mesh.invite ADD COLUMN
|
||||
capability_v_2 text`.
|
||||
|
||||
2. **`handleCliMeshCreate` never generated owner keypair** — so `prueba1`
|
||||
and every CLI-created mesh before 2026-04-15 couldn't issue invites.
|
||||
Added generation to create + self-heal in invite.
|
||||
|
||||
3. **`cli.ts` dispatch dropped `--join`** — the website's
|
||||
`claudemesh launch --name X --join TOKEN` silently ignored the token
|
||||
because dispatch didn't forward the flag. Fixed by forwarding to
|
||||
`runLaunch`.
|
||||
|
||||
4. **`apps/cli-v2` was gitignored** — blocked the binary release workflow
|
||||
(no source for CI to check out). Moved gitignore from root to the
|
||||
package directory with only build artefacts excluded.
|
||||
|
||||
5. **Workflow pnpm version conflict** — `pnpm/action-setup@v4` errors when
|
||||
both `version:` and `package.json#packageManager` are set. Removed the
|
||||
explicit version to defer to `packageManager`.
|
||||
|
||||
6. **Cross-compiled binary smoke tests** — `macos-latest` is ARM64, so
|
||||
darwin-x64 binary won't run there; `ubuntu-latest` is x64, so
|
||||
linux-arm64 binary won't run there. Smoke tests now run only when
|
||||
build arch matches runner arch.
|
||||
|
||||
7. **Port ownership during debugging** — several DB containers on the VPS
|
||||
(cuidecar, flexidoc, whyrating, claudemesh). Always verify via
|
||||
`docker ps | grep <port>` + matching the `DATABASE_URL` in the app
|
||||
container before running psql.
|
||||
|
||||
## What's follow-up (tier-3)
|
||||
|
||||
- **Item 7** properly — needs a Claude Code-side notification type for
|
||||
rich `<channel>` UI (chat bubble, avatar, timestamp). Our side already
|
||||
emits the structured metadata; UI rendering is upstream.
|
||||
- **Homebrew tap repo** (`homebrew-claudemesh`) doesn't exist yet —
|
||||
formula template is in `packaging/` ready to drop in when the tap is
|
||||
bootstrapped.
|
||||
- **winget submission** needs the first non-prerelease (cli-v1.0.0)
|
||||
cut, then PR to `microsoft/winget-pkgs`.
|
||||
- **Migrate all commands to `render.ts`** — foundation is shipped, old
|
||||
commands (peers, launch banner, etc.) still use ad-hoc
|
||||
`console.log` with color codes. Mechanical refactor.
|
||||
- **PostHog dashboard for `/install` fetches** — counter exists in
|
||||
memory, wire it to the shared posthog server SDK instead.
|
||||
|
||||
## Published version trail this session
|
||||
|
||||
- alpha.22 → 23 (previous session)
|
||||
- alpha.24: broker invite endpoint
|
||||
- alpha.25: CLI invite wire through generateInvite
|
||||
- alpha.26: email on Postmark honestly reported
|
||||
- alpha.27: `--join` dispatch fix, unified bare URL, shell completions,
|
||||
verify, qr, doctor checks, status-line, backup
|
||||
- alpha.28: url-handler, install --status-line
|
||||
- alpha.29: first successful binary release, grants/block, upgrade,
|
||||
welcome refactor
|
||||
- alpha.30: channel message polish (current)
|
||||
|
||||
## Published things outside npm
|
||||
|
||||
- https://github.com/alezmad/claudemesh/releases/tag/cli-v1.0.0-alpha.29
|
||||
— 5 platform binaries, SHA256SUMS
|
||||
- https://claudemesh.com/install — shell installer, binary fallback
|
||||
- https://claudemesh.com/i/... — invite short URLs (unchanged)
|
||||
232
.artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Anthropic Vision: Meshes & Invitations
|
||||
|
||||
**Status:** in progress · partial implementation 2026-04-10
|
||||
**Owner:** agutierrez
|
||||
**Scope:** `apps/web`, `packages/api`, `packages/db`, `apps/broker` (future), `apps/cli` (future)
|
||||
|
||||
---
|
||||
|
||||
## Guiding principles
|
||||
|
||||
1. **Identity is opaque, display is free-form.** Humans pick any name; the system uses random IDs.
|
||||
2. **Secrets never appear in URLs.** Links are capabilities, not credentials.
|
||||
3. **Defaults are obvious; advanced options are discoverable but hidden.**
|
||||
4. **Self-service wherever possible; admins don't become gatekeepers.**
|
||||
5. **Every visible action is also an auditable event.**
|
||||
|
||||
These mirror how Anthropic builds its own org/workspace/project model.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Meshes
|
||||
|
||||
### Problem
|
||||
Global uniqueness on `mesh.slug` creates name collisions at scale. Two users picking "platform" or "test" fight for the slug. At 50k users this is the default state.
|
||||
|
||||
### Decision
|
||||
**Drop the slug as an identity concept.** `mesh.id` (opaque, already random) is the canonical identifier everywhere (URLs, invites, broker lookups). `mesh.name` is a free-form display label, non-unique. `mesh.slug` is kept as a non-unique cosmetic string derived from the name at creation time, embedded in invite payloads for debugging.
|
||||
|
||||
### What this enables
|
||||
- Two users can both name their mesh "platform-team" with zero friction
|
||||
- URLs stay stable (`/meshes/{id}`) even if the user renames the mesh
|
||||
- No "slug taken" error state exists in the product anymore
|
||||
|
||||
### Tradeoff explicitly accepted
|
||||
Users lose the ability to type `claudemesh join platform-team` — but they never did, because the CLI takes signed invite tokens, not slugs. This capability was phantom.
|
||||
|
||||
### Implementation — DONE in this spec
|
||||
- [x] Drop `UNIQUE` constraint on `mesh.slug` (migration `0017_mesh-slug-non-unique.sql`)
|
||||
- [x] Remove `slug` field from `createMyMeshInputSchema`
|
||||
- [x] Remove slug field from `CreateMeshForm`
|
||||
- [x] Server-side `toSlug(name)` derives slug from name automatically
|
||||
- [x] Schema comment documents the non-canonical role of `slug`
|
||||
|
||||
### Future (optional, not in v0.1.x)
|
||||
- **Vanity slugs as a Pro feature:** one globally-unique handle per *account* (not per mesh), exposed as `claudemesh.com/@acme/...`. Sold as part of an org tier. This is where slug uniqueness actually pays for itself — against usernames, not against meshes.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Invitations
|
||||
|
||||
### Problems with the current invite system
|
||||
|
||||
| # | Problem | Severity |
|
||||
|---|---|---|
|
||||
| 1 | `mesh_root_key` is embedded in the invite URL as base64url JSON | 🔴 **Security** |
|
||||
| 2 | Invite URLs are ~400 chars of opaque base64url | 🟡 UX |
|
||||
| 3 | No invite-by-email; only shareable link | 🟡 UX |
|
||||
| 4 | Required form fields (role, maxUses, expiresInDays) for every invite | 🟡 UX |
|
||||
| 5 | Landing page does not clearly preview role/consent | 🟡 UX |
|
||||
| 6 | No audit trail for invites received-but-never-clicked | 🟢 Polish |
|
||||
| 7 | `ic://` link scheme is vestigial, nothing registers the handler | 🟢 Polish |
|
||||
|
||||
### Severity 🔴 — the root key leak
|
||||
|
||||
Current canonical invite bytes:
|
||||
```
|
||||
v | mesh_id | mesh_slug | broker_url | expires_at | mesh_root_key | role | owner_pubkey
|
||||
```
|
||||
|
||||
`mesh_root_key` is a 32-byte shared secret used by all channel and broadcast encryption in the mesh. Once it lives in a URL:
|
||||
- Slack/Telegram/Discord link previews fetch and cache the URL → root key is in those caches
|
||||
- Browser history, sync, analytics pixels, error logs → root key persists anywhere URLs persist
|
||||
- A screenshot of the invite link is a compromise
|
||||
- Revoking the invite does **not** rotate the key, so exposure is permanent
|
||||
|
||||
**Anthropic would never do this.** The fix is a protocol change: the invite grants the *right* to receive the key, it is not the key itself.
|
||||
|
||||
### The v2 invite protocol (spec only in this doc — NOT implemented this session)
|
||||
|
||||
**Design goals**
|
||||
1. No secret material in any user-visible string (URL, QR, paste buffer)
|
||||
2. Invite URLs are short (<30 chars): `claudemesh.com/i/abc12345`
|
||||
3. Existing v1 invites continue to work during a deprecation window
|
||||
4. Revocation is clean and immediate
|
||||
5. One recipient = one root-key-delivery capability
|
||||
|
||||
**Flow**
|
||||
```
|
||||
Admin creates invite (v2):
|
||||
server generates short_code (base62, 8 chars, unique)
|
||||
server stores in DB: {id, mesh_id, code, role, max_uses, expires_at, signed_capability}
|
||||
signed_capability = ed25519_sign(canonical_v2_bytes, mesh.owner_secret_key)
|
||||
canonical_v2_bytes = v=2 | mesh_id | invite_id | expires_at | role | owner_pubkey
|
||||
NOTE: no root_key, no broker_url
|
||||
returns: claudemesh.com/i/{code}
|
||||
|
||||
Recipient clicks the link:
|
||||
web: GET /api/public/invites/code/{code}
|
||||
returns {mesh_name, inviter_name, role, expires_at, member_count}
|
||||
no secrets, no signature leaked
|
||||
web: shows consent landing: "You are joining ACME as a Member"
|
||||
recipient authenticates (sign up / log in) OR runs CLI
|
||||
|
||||
Recipient claims the invite:
|
||||
CLI: generates session ed25519 keypair (ephemeral)
|
||||
CLI: connects to broker ws://ic.claudemesh.com/ws
|
||||
CLI: sends { type: "claim_invite", code, recipient_pubkey }
|
||||
broker: looks up invite by code
|
||||
broker: verifies signed_capability against mesh.owner_pubkey
|
||||
broker: checks expires_at, max_uses vs used_count, revoked_at
|
||||
broker: increments used_count, creates mesh.member row
|
||||
broker: seals mesh.root_key with crypto_box_seal to recipient_pubkey
|
||||
broker: returns { sealed_root_key, mesh_id, member_id }
|
||||
CLI: unseals with its secret key → has root_key
|
||||
CLI: starts normal mesh traffic
|
||||
|
||||
Revocation:
|
||||
admin sets invite.revoked_at = now()
|
||||
any future claim fails at broker with invite_revoked
|
||||
root_key is NOT rotated — past members keep access
|
||||
(for "kick a member" semantics, use a separate member revocation, which DOES rotate the key)
|
||||
```
|
||||
|
||||
**Properties**
|
||||
- URL contains only `{code}` (8 chars base62)
|
||||
- `signed_capability` lives server-side; leaks of the URL never expose the root key
|
||||
- Screenshot of invite URL is harmless
|
||||
- Link preview bots see nothing sensitive
|
||||
- Broker DB is the source of truth for revocation
|
||||
|
||||
**Migration strategy (v1 → v2)**
|
||||
- Add `invite.code`, `invite.v2_capability` columns (nullable for existing rows)
|
||||
- `createMyInvite` generates BOTH v1 token (legacy) and v2 code
|
||||
- Web invite UI displays the short URL by default, long URL as "Legacy format" disclosure
|
||||
- Broker accepts both formats until v0.2.0
|
||||
- Announce deprecation window; at v0.2.0 the long-format endpoints 410 Gone
|
||||
|
||||
**Status update 2026-04-10 — v2 is now being implemented in parallel**
|
||||
|
||||
The scope that was deferred at the top of the session is actively landing in a coordinated multi-agent push:
|
||||
- Broker: new `/api/public/invites/:code/claim` endpoint, `crypto_box_seal` against recipient x25519 pubkey, signed capability verification, single-use accounting.
|
||||
- DB: `mesh.invite.version` int, `mesh.invite.capability_v2` text nullable, `mesh.invite.claimed_by_pubkey` text nullable. New table `mesh.pending_invite` for email invites.
|
||||
- CLI / web claim client: generates a fresh x25519 keypair (separate from the ed25519 identity), POSTs the pubkey, unseals the returned `sealed_root_key`, then verifies `canonical_v2` against `owner_pubkey`.
|
||||
- Email invites (parallel track): Postmark delivery wired on top of `pending_invite`; the email body carries the same `claudemesh.com/i/{code}` short URL.
|
||||
|
||||
v1 invites continue to work throughout v0.1.x. v1 endpoints return `410 Gone` at v0.2.0.
|
||||
|
||||
Docs updated in the same session: `SPEC.md` §14b, `docs/protocol.md` (v2 invites subsection), `docs/roadmap.md` (in progress).
|
||||
|
||||
---
|
||||
|
||||
### Severity 🟡 — implemented this session
|
||||
|
||||
#### Short invite codes (URL shortening, backward-compatible)
|
||||
|
||||
Additive: invites now get both a long token AND a short opaque code. The web app prefers the short URL.
|
||||
|
||||
**DB:** new nullable `invite.code` column, unique. New migration `0018_invite-short-code.sql`.
|
||||
|
||||
**API:** `createMyInvite` generates `code` (base62, 8 chars, collision-retry). Returns `shortUrl` alongside `inviteLink` / `joinUrl`.
|
||||
|
||||
**Web:** new server route `/i/[code]/page.tsx` that resolves the code server-side and redirects to the canonical `/join/[token]` page. Invite generator UI shows the short URL as the primary "Copy link" target.
|
||||
|
||||
**Backward compat:** existing invites without a `code` keep working via their long token. No broker/CLI changes.
|
||||
|
||||
**This is NOT the v2 protocol.** It only fixes the URL-length problem. The root key is still embedded in the long token that the short code resolves to. The short code is a URL shortener, not a capability boundary. Document this clearly so nobody confuses the two.
|
||||
|
||||
---
|
||||
|
||||
#### Collapsed advanced fields
|
||||
|
||||
The invite form asks for `role`, `max uses`, `expires in days` upfront. 90% of users only ever create `{ role: member, max_uses: 1, expires_in_days: 7 }`.
|
||||
|
||||
Change: defaults are pre-filled; the three fields are hidden behind an "Advanced" disclosure.
|
||||
|
||||
---
|
||||
|
||||
### Severity 🟡 — deferred
|
||||
|
||||
#### Invite by email
|
||||
|
||||
- Requires an `invitation_email` table or equivalent pending-invites state
|
||||
- Requires wire-up to email delivery (already have Postmark via turbostarter)
|
||||
- Out of scope this session; fits naturally on top of v2 invite protocol
|
||||
|
||||
#### Consent landing redesign
|
||||
|
||||
- The `/join/[token]` page should show: mesh name, inviter, role being granted, member count, expiry, explicit "Join as Member of ACME" button
|
||||
- Needs a design pass
|
||||
- Deferred
|
||||
|
||||
---
|
||||
|
||||
### Severity 🟢 — deferred
|
||||
|
||||
- Remove `ic://` scheme — it's dead, nothing handles it, safe to delete in v0.1.x cleanup
|
||||
- Received-but-not-clicked audit — falls out of email invites for free
|
||||
|
||||
---
|
||||
|
||||
## Summary table
|
||||
|
||||
| Change | Status | File(s) |
|
||||
|---|---|---|
|
||||
| Drop global slug uniqueness | ✅ done | `packages/db/src/schema/mesh.ts`, migration `0017` |
|
||||
| Remove slug from create-mesh form | ✅ done | `apps/web/src/modules/mesh/create-mesh-form.tsx` |
|
||||
| Server-derived slug from name | ✅ done | `packages/api/src/modules/mesh/mutations.ts` |
|
||||
| Short invite codes (URL shortener) | ✅ done | `packages/db` migration `0018`, api, web `/i/[code]` |
|
||||
| Collapse invite advanced fields | ✅ done | `apps/web/src/modules/mesh/invite-generator.tsx` |
|
||||
| v2 invite protocol (root key out of URL) | 🚧 in progress | broker `/api/public/invites/:code/claim`, `mesh.invite.version` + `capability_v2` + `claimed_by_pubkey`, CLI/web claim client |
|
||||
| Invite by email | 🚧 in progress | `mesh.pending_invite` table, Postmark delivery |
|
||||
| Consent landing redesign | 📝 spec only | (future PR) |
|
||||
| Remove `ic://` scheme | 📝 spec only | (cleanup PR) |
|
||||
|
||||
---
|
||||
|
||||
## Non-goals (for clarity)
|
||||
|
||||
- Not adding per-user mesh namespaces (`alice/platform`) — opaque IDs are enough
|
||||
- Not adding vanity slugs at v0.1.x — can come as a Pro tier later
|
||||
- Not changing the broker wire protocol this session
|
||||
- Not rewriting the CLI join flow this session
|
||||
|
||||
---
|
||||
|
||||
## Post-implementation checklist
|
||||
|
||||
- [x] Web builds without type errors on changed files
|
||||
- [x] Migrations run on production DB (`0017` applied; `0018` after review)
|
||||
- [x] No broker protocol change (backward compat verified)
|
||||
- [x] Existing long-token invites continue to resolve
|
||||
- [x] New invites expose `shortUrl` in the API response
|
||||
593
.artifacts/specs/2026-04-10-cli-auth-device-code-pat.md
Normal file
@@ -0,0 +1,593 @@
|
||||
# CLI Auth — Device Code Flow + Personal Access Tokens
|
||||
|
||||
**Status:** spec
|
||||
**Created:** 2026-04-10
|
||||
**Owner:** CLI-Dev (implementation), Orchestrator (spec)
|
||||
**Target version:** v0.11.0
|
||||
**Related:** `2026-04-10-anthropic-vision-meshes-invites.md`, `2026-04-10-cli-wizard-architecture-refactor.md`
|
||||
|
||||
## Goal
|
||||
|
||||
The CLI is a first-class client. From a fresh terminal, with zero prior browser interaction, a user can:
|
||||
|
||||
```
|
||||
claudemesh login # device-code OAuth, browser handshake
|
||||
claudemesh create "Platform team" # creates real mesh via /api/my/meshes
|
||||
claudemesh invite --email alice@x.com # generates invite, sends email
|
||||
claudemesh launch --mesh platform-team -y # spawns Claude Code in the mesh
|
||||
```
|
||||
|
||||
For CI / scripting / non-interactive contexts, PAT works too:
|
||||
|
||||
```
|
||||
claudemesh login --token cm_pat_abc123
|
||||
claudemesh create "CI test mesh" --json | jq .id
|
||||
```
|
||||
|
||||
This is the auth substrate that unblocks the "Anthropic vision" — every other dashboard-only feature (meshes, invites, members, billing) becomes CLI-accessible after this lands.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- SSO / SAML / enterprise IdP integration (later, post-1.0)
|
||||
- Refresh tokens with rotation (long-lived API keys are sufficient for v1)
|
||||
- Multi-account switching (one logged-in identity per `~/.claudemesh/auth.json`)
|
||||
- Device fleet management UI (single "revoke" button per token is enough for v1)
|
||||
|
||||
## Auth model overview
|
||||
|
||||
Two coexisting credential types, both backed by **Better Auth's `apiKey` plugin**:
|
||||
|
||||
| Type | Created via | Lifetime | Use case | Storage |
|
||||
|---|---|---|---|---|
|
||||
| **Device-code session token** | `claudemesh login` (OAuth-style browser handshake) | 90 days, auto-renew on use | Interactive humans on their workstation | `~/.claudemesh/auth.json` |
|
||||
| **Personal access token (PAT)** | Dashboard → Settings → CLI tokens → Generate | User-chosen (30d / 90d / 1y / never), explicit revocation | CI, scripts, automation, server-side cron | Anywhere the user puts it; CLI reads from `--token` flag, env var, or `auth.json` |
|
||||
|
||||
Both flow through the same `Authorization: Bearer cm_<type>_<random>` header. The API doesn't care which one it gets — it just validates against the `api_key` table.
|
||||
|
||||
**Token format:**
|
||||
- `cm_session_<32-byte base32>` — device-code sessions
|
||||
- `cm_pat_<32-byte base32>` — personal access tokens
|
||||
|
||||
The `cm_` prefix lets us scan for leaked tokens with regex (e.g. GitHub secret scanning, internal scripts). The middle segment (`session` / `pat`) is for human readability in token lists, not for security.
|
||||
|
||||
## User flows
|
||||
|
||||
### 1. First-time login (interactive happy path)
|
||||
|
||||
```
|
||||
$ claudemesh login
|
||||
|
||||
██ claudemesh login
|
||||
|
||||
Opening browser for authentication…
|
||||
|
||||
If your browser didn't open, visit:
|
||||
https://claudemesh.com/cli-auth?code=ABCD-EFGH
|
||||
|
||||
Enter this code:
|
||||
ABCD-EFGH
|
||||
|
||||
Waiting for confirmation… ⠋
|
||||
```
|
||||
|
||||
In the browser:
|
||||
1. User lands on `/cli-auth?code=ABCD-EFGH`
|
||||
2. If not signed in, Better Auth login screen appears, then redirects back
|
||||
3. User sees a confirmation card:
|
||||
```
|
||||
Link this CLI session?
|
||||
Code: ABCD-EFGH
|
||||
Device: Alejandro's MacBook Pro · darwin · arm64
|
||||
Expires in 9:47
|
||||
[Approve] [Deny]
|
||||
```
|
||||
4. User clicks Approve
|
||||
|
||||
CLI polls every 1.5s, sees `approved`, receives token, writes `~/.claudemesh/auth.json` with `0600`, prints:
|
||||
|
||||
```
|
||||
✔ Authenticated as Alejandro Gutiérrez
|
||||
✔ Token saved to ~/.claudemesh/auth.json
|
||||
✔ Synced 3 meshes: alexis-mou, dev, claudefarm
|
||||
|
||||
Run claudemesh --help to get started.
|
||||
```
|
||||
|
||||
### 2. First-time login (PAT, non-interactive)
|
||||
|
||||
```
|
||||
$ claudemesh login --token cm_pat_abc123def456...
|
||||
✔ Authenticated as Alejandro Gutiérrez (via PAT "ci-deploy")
|
||||
✔ Token saved to ~/.claudemesh/auth.json
|
||||
```
|
||||
|
||||
Or one-shot, no save:
|
||||
|
||||
```
|
||||
$ CLAUDEMESH_TOKEN=cm_pat_abc123 claudemesh create "test"
|
||||
```
|
||||
|
||||
### 3. Already logged in, runs a command
|
||||
|
||||
```
|
||||
$ claudemesh create "Platform team"
|
||||
✔ Created mesh platform-team (id: q5RI89Fl…)
|
||||
✔ Joined locally
|
||||
▸ Invite peers: claudemesh invite --mesh platform-team
|
||||
```
|
||||
|
||||
No auth prompt — token in `auth.json` is used silently.
|
||||
|
||||
### 4. Token expired or revoked
|
||||
|
||||
```
|
||||
$ claudemesh peers
|
||||
✘ Authentication failed (token expired or revoked)
|
||||
|
||||
Run claudemesh login to re-authenticate.
|
||||
```
|
||||
|
||||
Exit code `2`. The `auth.json` is **not** auto-deleted (user might be debugging) but the next `claudemesh login` overwrites it cleanly.
|
||||
|
||||
### 5. Wizard launch flow with auth integration
|
||||
|
||||
When `claudemesh` (bare, no auth) is run:
|
||||
|
||||
```
|
||||
██ claudemesh
|
||||
|
||||
▸ Sign in (opens browser)
|
||||
Paste a personal access token
|
||||
Join a mesh via invite URL
|
||||
Exit
|
||||
```
|
||||
|
||||
After auth completes, the wizard transitions naturally into the launch flow (mesh picker → name → role → confirm → handoff). One uninterrupted experience from "fresh install" to "Claude Code in a mesh."
|
||||
|
||||
### 6. CI / non-interactive
|
||||
|
||||
```
|
||||
# .github/workflows/test.yml
|
||||
- run: |
|
||||
claudemesh login --token ${{ secrets.CLAUDEMESH_PAT }}
|
||||
claudemesh create "CI run $GITHUB_RUN_ID" --json > mesh.json
|
||||
```
|
||||
|
||||
Or zero-state:
|
||||
|
||||
```
|
||||
- env:
|
||||
CLAUDEMESH_TOKEN: ${{ secrets.CLAUDEMESH_PAT }}
|
||||
run: claudemesh create "CI run $GITHUB_RUN_ID" --json
|
||||
```
|
||||
|
||||
Token resolution order: `--token` flag > `CLAUDEMESH_TOKEN` env var > `~/.claudemesh/auth.json`.
|
||||
|
||||
### 7. Logout
|
||||
|
||||
```
|
||||
$ claudemesh logout
|
||||
✔ Token revoked on server
|
||||
✔ Removed ~/.claudemesh/auth.json
|
||||
```
|
||||
|
||||
`logout` calls `DELETE /api/my/cli/sessions/current` to revoke server-side, then unlinks the local file. Best-effort: if the server call fails, still delete locally and warn.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend — Better Auth `apiKey` plugin
|
||||
|
||||
Better Auth ships an `apiKey` plugin that handles:
|
||||
- Token generation (cryptographically random)
|
||||
- Hashed storage (only the hash hits the DB; raw token never persisted)
|
||||
- Verification middleware (validates `Authorization: Bearer …`)
|
||||
- Per-token metadata (name, scopes, expiry, last-used)
|
||||
- Per-token revocation
|
||||
|
||||
We use it for both PAT and device-code sessions. Device-code sessions just have a marker in metadata distinguishing them from user-generated PATs.
|
||||
|
||||
**Wire-up:** `apps/web/src/lib/auth/index.ts` (or wherever Better Auth is initialized) adds:
|
||||
|
||||
```ts
|
||||
import { apiKey } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
// …existing config
|
||||
plugins: [
|
||||
// …
|
||||
apiKey({
|
||||
enableMetadata: true,
|
||||
apiKeyHeaders: ["x-api-key", "authorization"],
|
||||
defaultPrefix: "cm_",
|
||||
rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 100 },
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Backend — device-code table
|
||||
|
||||
The `apiKey` plugin doesn't ship device-code flow out of the box. We add a small table + 4 endpoints on top.
|
||||
|
||||
```sql
|
||||
-- packages/db/migrations/0020_cli-device-code.sql
|
||||
CREATE TABLE cli_device_code (
|
||||
device_code text PRIMARY KEY, -- opaque random, sent to CLI
|
||||
user_code text UNIQUE NOT NULL, -- short human code: "ABCD-EFGH"
|
||||
user_id text REFERENCES "user"(id), -- nullable until approved
|
||||
api_key_id text REFERENCES api_key(id), -- the issued token, set on approve
|
||||
device_name text NOT NULL, -- "Alejandro's MacBook Pro"
|
||||
device_os text NOT NULL, -- "darwin"
|
||||
device_arch text NOT NULL, -- "arm64"
|
||||
ip_address text, -- for audit
|
||||
user_agent text,
|
||||
status text NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'denied' | 'expired'
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL, -- created_at + 10 min
|
||||
approved_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX cli_device_code_user_code_idx ON cli_device_code(user_code);
|
||||
CREATE INDEX cli_device_code_status_expires_idx ON cli_device_code(status, expires_at);
|
||||
```
|
||||
|
||||
A scheduled job (or lazy cleanup on insert) deletes rows where `status='expired'` AND `expires_at < now() - interval '7 days'`.
|
||||
|
||||
### Backend — endpoints
|
||||
|
||||
All under `apps/web/src/app/api/auth/cli/` (or wherever you keep public auth routes — these need to be **unauthed** since the CLI has no token yet).
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/auth/cli/device-code` | none | CLI requests a new device code. Body: `{ device_name, device_os, device_arch }`. Returns `{ device_code, user_code, expires_at, verification_url }`. |
|
||||
| `GET` | `/api/auth/cli/device-code/:device_code` | none | CLI polls for status. Returns `{ status: 'pending'|'approved'|'denied'|'expired', token?: string, user?: { id, name, email } }`. Token only present when status=approved, and only **once** (subsequent polls return approved without token). |
|
||||
| `POST` | `/api/auth/cli/device-code/:user_code/approve` | session | Browser confirms. Creates an `api_key` row with metadata `{ kind: 'session', device_name, device_code }`, sets `cli_device_code.api_key_id`, status=approved. |
|
||||
| `POST` | `/api/auth/cli/device-code/:user_code/deny` | session | Browser denies. Sets status=denied. |
|
||||
|
||||
Authed endpoints (under `/api/my/cli/`):
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/my/cli/sessions` | List active CLI sessions for the user (devices, last seen, created). |
|
||||
| `DELETE` | `/api/my/cli/sessions/:id` | Revoke a specific session. |
|
||||
| `POST` | `/api/my/cli/tokens` | Create a PAT. Body: `{ name, expires_in_days?, scopes? }`. Returns the raw token **once**. |
|
||||
| `GET` | `/api/my/cli/tokens` | List PATs (no raw values, just metadata). |
|
||||
| `DELETE` | `/api/my/cli/tokens/:id` | Revoke a PAT. |
|
||||
|
||||
### Backend — middleware
|
||||
|
||||
Existing `enforceAuth` (in `packages/api/src/utils/`) currently reads cookies. Extend it to also accept `Authorization: Bearer cm_…`:
|
||||
|
||||
```ts
|
||||
export async function enforceAuth(ctx) {
|
||||
const bearer = ctx.req.headers.get("authorization")?.replace(/^Bearer /, "");
|
||||
if (bearer?.startsWith("cm_")) {
|
||||
const result = await auth.api.verifyApiKey({ key: bearer });
|
||||
if (result.valid) {
|
||||
// record last_used_at, increment usage counter
|
||||
return { user: result.user, via: "apiKey", apiKey: result.apiKey };
|
||||
}
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid token" });
|
||||
}
|
||||
// …existing cookie-based auth
|
||||
}
|
||||
```
|
||||
|
||||
The `apiKey` plugin handles `last_used_at` updates automatically.
|
||||
|
||||
### Backend — web route
|
||||
|
||||
`apps/web/src/app/[locale]/cli-auth/page.tsx`:
|
||||
|
||||
- Reads `?code=ABCD-EFGH` from query string
|
||||
- If no session, redirects to `/login?next=/cli-auth?code=ABCD-EFGH`
|
||||
- If session, fetches device code metadata via server component, renders confirmation card
|
||||
- Approve button → `POST /api/auth/cli/device-code/:user_code/approve`
|
||||
- Deny button → `POST /api/auth/cli/device-code/:user_code/deny`
|
||||
- After approve, shows: "✓ CLI authenticated. Return to your terminal."
|
||||
|
||||
Mobile-friendly. Confirmation card shows device fingerprint so the user can verify they're approving the right session.
|
||||
|
||||
### Backend — dashboard PAT UI
|
||||
|
||||
`apps/web/src/app/[locale]/dashboard/settings/cli-tokens/page.tsx`:
|
||||
|
||||
- List of existing PATs (name, created, last used, expires)
|
||||
- "Generate new token" button → modal with name + expiry picker
|
||||
- After creation, show raw token once with copy button + warning ("This token will not be shown again")
|
||||
- Per-row revoke button
|
||||
|
||||
Reuses existing dashboard layout. Should be ~150 lines including the modal.
|
||||
|
||||
### CLI — file layout
|
||||
|
||||
```
|
||||
apps/cli/src/
|
||||
├── commands/
|
||||
│ ├── login.ts # NEW
|
||||
│ ├── logout.ts # NEW
|
||||
│ ├── whoami.ts # NEW
|
||||
│ ├── create.ts # rewrite to call API
|
||||
│ ├── invite.ts # NEW
|
||||
│ ├── sync.ts # rewrite to call API
|
||||
│ └── …existing
|
||||
└── lib/
|
||||
├── auth-store.ts # NEW: read/write ~/.claudemesh/auth.json
|
||||
├── api-client.ts # NEW: typed fetch wrapper
|
||||
├── device-info.ts # NEW: collect hostname, os, arch for device-code request
|
||||
└── …existing
|
||||
```
|
||||
|
||||
### CLI — `auth-store.ts`
|
||||
|
||||
```ts
|
||||
// ~/.claudemesh/auth.json
|
||||
type AuthFile = {
|
||||
version: 1;
|
||||
token: string; // cm_session_… or cm_pat_…
|
||||
user: { id: string; name: string; email: string };
|
||||
created_at: string; // ISO
|
||||
source: "device-code" | "pat" | "env";
|
||||
};
|
||||
```
|
||||
|
||||
Read priority: `--token` flag > `CLAUDEMESH_TOKEN` env > `auth.json`.
|
||||
Write only on `login` success. File mode `0600`. Parent dir `0700`.
|
||||
On read, if file mode is too permissive, log a warning and continue.
|
||||
|
||||
### CLI — `api-client.ts`
|
||||
|
||||
Thin wrapper over `fetch`:
|
||||
|
||||
```ts
|
||||
export class ClaudemeshApi {
|
||||
constructor(private opts: { baseUrl: string; token: string }) {}
|
||||
|
||||
async createMesh(input: { name: string; slug?: string }) { … }
|
||||
async listMeshes() { … }
|
||||
async createInvite(input: { meshId: string; email?: string; role?: string }) { … }
|
||||
async listSessions() { … }
|
||||
async revokeSession(id: string) { … }
|
||||
async whoami() { … }
|
||||
}
|
||||
```
|
||||
|
||||
Type definitions live in `packages/api/src/contracts/cli.ts` (new file) — generated from the existing tRPC routers as plain types so the CLI doesn't need to import the whole tRPC client.
|
||||
|
||||
Base URL from `CLAUDEMESH_API_URL` env var, defaults to `https://claudemesh.com`. Allows local dev against `http://localhost:3000`.
|
||||
|
||||
### CLI — device-code login flow
|
||||
|
||||
```ts
|
||||
// commands/login.ts
|
||||
async function deviceCodeLogin() {
|
||||
const device = collectDeviceInfo();
|
||||
const { device_code, user_code, expires_at, verification_url } =
|
||||
await api.requestDeviceCode(device);
|
||||
|
||||
console.log(` Opening ${verification_url}…`);
|
||||
console.log(` Code: ${user_code}`);
|
||||
|
||||
await openBrowser(`${verification_url}?code=${user_code}`);
|
||||
|
||||
const spinner = ora("Waiting for confirmation").start();
|
||||
const deadline = new Date(expires_at).getTime();
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(1500);
|
||||
const result = await api.pollDeviceCode(device_code);
|
||||
if (result.status === "approved") {
|
||||
spinner.succeed("Authenticated");
|
||||
await authStore.write({ token: result.token, user: result.user, source: "device-code" });
|
||||
await syncMeshes();
|
||||
return;
|
||||
}
|
||||
if (result.status === "denied") {
|
||||
spinner.fail("Denied in browser");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
spinner.fail("Timed out");
|
||||
process.exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
Polls every 1.5s. Server returns `{ slow_down: true }` if polled too fast (rate limit at 1/sec).
|
||||
|
||||
## Security
|
||||
|
||||
1. **Tokens are hashed at rest** (Better Auth `apiKey` plugin handles this with bcrypt or argon2).
|
||||
2. **Raw tokens shown to user once.** PATs in dashboard, device-code tokens via `claudemesh login` output. Never logged, never re-displayable.
|
||||
3. **`auth.json` is `0600`.** CLI refuses to write if parent dir can't be made `0700`. Warns on read if mode is wider.
|
||||
4. **Token prefix `cm_` enables secret scanning.** Document the regex `cm_(session|pat)_[a-z0-9]{32,}` in security docs so GitHub secret scanning, GitGuardian, etc. can detect leaks.
|
||||
5. **`/api/auth/cli/device-code/:device_code` polling is rate-limited** to 1 req/sec per IP per device_code. Returns `429` with `slow_down: true` body.
|
||||
6. **Device codes expire in 10 minutes.** Approved-but-unclaimed tokens stay valid (the polling endpoint still returns the token for 60 seconds after approval, then the device_code row is GC'd).
|
||||
7. **Audit logging.** Every device-code approval, PAT creation, and PAT revocation emits an audit event (`auth.cli.session.created`, `auth.cli.pat.created`, etc.). Stored in existing audit log if there is one, otherwise new `audit_log` table.
|
||||
8. **Session invalidation on password change.** When a user changes their password via Better Auth, all `cli_session` `api_key` rows for that user are revoked. PATs are NOT auto-revoked (they're explicitly user-managed).
|
||||
9. **Token revocation is immediate.** `auth.api.verifyApiKey` checks DB on every request — no in-memory cache.
|
||||
10. **No CSRF concern** for device-code endpoints — the unauthed ones don't act on user state, the authed ones use Better Auth's existing CSRF protection.
|
||||
|
||||
## Wizard UX integration
|
||||
|
||||
The current welcome wizard already has:
|
||||
```
|
||||
▸ Create account (new to claudemesh)
|
||||
Sign in (existing account)
|
||||
Paste an invite URL
|
||||
Exit
|
||||
```
|
||||
|
||||
After this spec lands, the welcome screen becomes:
|
||||
```
|
||||
██ claudemesh
|
||||
|
||||
▸ Sign in ← device-code OAuth
|
||||
Paste an access token ← PAT path
|
||||
Join via invite URL ← unchanged
|
||||
Create account ← opens /register, then back to login
|
||||
Exit
|
||||
```
|
||||
|
||||
"Sign in" becomes the headline option. The current "Create account" still opens browser to `/register` but flows back through the device-code handshake instead of a custom callback.
|
||||
|
||||
Once authenticated, the wizard transitions to:
|
||||
```
|
||||
██ claudemesh launch
|
||||
|
||||
Account ✔ Alejandro Gutiérrez
|
||||
Mesh ▸ (pick one — 3 available)
|
||||
Name ✔ Alexis (from --name)
|
||||
Role ▸ (pick one)
|
||||
|
||||
▸ Continue
|
||||
Cancel
|
||||
```
|
||||
|
||||
Status rows show what's filled and what's left. Mesh picker fetches from `GET /api/my/meshes` via the freshly minted token.
|
||||
|
||||
This integrates cleanly with the wizard architecture refactor in `2026-04-10-cli-wizard-architecture-refactor.md`: auth becomes one screen in the launch flow with `isComplete: s => s.user !== null`. On a fresh machine the auth screen runs; on a returning machine it's auto-skipped.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Browser doesn't open | Print URL prominently, keep polling |
|
||||
| Network down during poll | Retry with exponential backoff (1.5s → 3s → 6s, max 30s) |
|
||||
| Device code expires | Print "Login timed out, run `claudemesh login` to retry", exit 1 |
|
||||
| Token rejected by API | Print "Authentication failed", suggest `claudemesh login`, exit 2 |
|
||||
| `auth.json` corrupted | Print "Auth file corrupted, run `claudemesh login`", exit 2 |
|
||||
| `auth.json` permissions wrong | Warn, fix to `0600`, continue |
|
||||
| PAT pasted to `--token` is malformed | Print "Invalid token format (expected `cm_pat_…`)", exit 1 |
|
||||
| PAT pasted to `--token` is valid format but unknown | API returns 401, print "Token rejected", exit 2 |
|
||||
| Two CLI instances poll simultaneously | Both get the same approved status; first to read gets the token, second gets `{ status: 'approved', token: null }` (already_claimed). Document this. |
|
||||
| User clicks Approve in browser, then closes tab | CLI's poll catches it, login succeeds. The browser tab closure is irrelevant. |
|
||||
| User completes login on machine A, then runs `claudemesh login` on machine B with same account | Both sessions coexist as separate `api_key` rows. `claudemesh whoami --sessions` shows both. |
|
||||
|
||||
## Implementation phases
|
||||
|
||||
Each phase ships independently and is independently testable.
|
||||
|
||||
### Phase 1 — Backend foundation (4–6 hours)
|
||||
|
||||
- [ ] Wire Better Auth `apiKey` plugin in `apps/web/src/lib/auth/`
|
||||
- [ ] Migration `0020_cli-device-code.sql`
|
||||
- [ ] Drizzle schema for `cli_device_code` in `packages/db/src/schema/auth.ts`
|
||||
- [ ] Endpoints: `POST /api/auth/cli/device-code`, `GET /api/auth/cli/device-code/:device_code`, `POST /api/auth/cli/device-code/:user_code/approve`, `POST /api/auth/cli/device-code/:user_code/deny`
|
||||
- [ ] Extend `enforceAuth` middleware to accept `Authorization: Bearer cm_…`
|
||||
- [ ] Endpoints: `POST /api/my/cli/tokens`, `GET /api/my/cli/tokens`, `DELETE /api/my/cli/tokens/:id`, `GET /api/my/cli/sessions`, `DELETE /api/my/cli/sessions/:id`
|
||||
- [ ] Unit tests for token verification and device-code state machine
|
||||
|
||||
### Phase 2 — Web routes (3–4 hours)
|
||||
|
||||
- [ ] `/cli-auth?code=...` page (server component + approve/deny client component)
|
||||
- [ ] `/dashboard/settings/cli-tokens` page (list + create modal + revoke)
|
||||
- [ ] Translations for both pages (en, es)
|
||||
- [ ] E2E test: full device-code happy path with Playwright
|
||||
|
||||
### Phase 3 — CLI auth core (4–5 hours)
|
||||
|
||||
- [ ] `lib/device-info.ts` — collect hostname, os, arch
|
||||
- [ ] `lib/auth-store.ts` — read/write `~/.claudemesh/auth.json` with mode checks
|
||||
- [ ] `lib/api-client.ts` — typed fetch wrapper with bearer header
|
||||
- [ ] `commands/login.ts` — device-code flow + `--token` PAT path
|
||||
- [ ] `commands/logout.ts` — revoke + delete local
|
||||
- [ ] `commands/whoami.ts` — print current identity + token source
|
||||
- [ ] Token resolution helper (`--token` > `CLAUDEMESH_TOKEN` > `auth.json`)
|
||||
- [ ] Unit tests for auth-store and token resolution
|
||||
|
||||
### Phase 4 — CLI commands wired to API (3–4 hours)
|
||||
|
||||
- [ ] Rewrite `commands/create.ts` to call `POST /api/my/meshes`
|
||||
- [ ] New `commands/invite.ts` with `--email`, `--mesh`, `--role`, `--expires-in`
|
||||
- [ ] Rewrite `commands/sync.ts` to call `GET /api/my/meshes` and reconcile local config
|
||||
- [ ] Update `commands/list.ts` to show server-side meshes too
|
||||
- [ ] Integration tests against staging broker + web
|
||||
|
||||
### Phase 5 — Wizard integration (3–4 hours)
|
||||
|
||||
- [ ] Welcome screen new options (Sign in / Paste token / Create account / Join invite)
|
||||
- [ ] Auth screen as a flow step with `isComplete: s => s.user !== null`
|
||||
- [ ] Status rows pattern showing auth state during launch
|
||||
- [ ] First-run detection (no `auth.json`) → auto-route to login
|
||||
|
||||
### Phase 6 — Polish, docs, ship (2–3 hours)
|
||||
|
||||
- [ ] Update `README.md`, `apps/cli/README.md`, `docs/quickstart.md`
|
||||
- [ ] CHANGELOG entry for v0.11.0
|
||||
- [ ] Telemetry events for `auth.cli.login.{start,success,fail}`
|
||||
- [ ] Bump `apps/cli/package.json` to `0.11.0`
|
||||
- [ ] Publish to npm
|
||||
- [ ] Deploy broker / web (no broker changes, web for new routes)
|
||||
|
||||
**Total estimate:** 19–26 hours of focused work. Realistic: 3–4 days with testing and review.
|
||||
|
||||
## Dependencies between phases
|
||||
|
||||
```
|
||||
Phase 1 (backend) ──┬─→ Phase 2 (web routes)
|
||||
└─→ Phase 3 (CLI auth core)
|
||||
│
|
||||
└─→ Phase 4 (commands)
|
||||
│
|
||||
└─→ Phase 5 (wizard)
|
||||
│
|
||||
└─→ Phase 6 (ship)
|
||||
```
|
||||
|
||||
Phase 1 and 2 can be parallelized after the schema lands. Phase 3 needs Phase 1 endpoints live (even if on staging). Phase 4 onwards is strictly serial.
|
||||
|
||||
## Telemetry
|
||||
|
||||
Emit these events (PostHog or whatever the existing analytics are):
|
||||
|
||||
- `cli.login.started` — properties: `{ method: 'device-code' | 'pat' }`
|
||||
- `cli.login.succeeded` — properties: `{ method, user_id }`
|
||||
- `cli.login.failed` — properties: `{ method, reason }`
|
||||
- `cli.logout` — properties: `{ user_id }`
|
||||
- `cli.command.executed` — properties: `{ command, exit_code, duration_ms, authenticated: boolean }`
|
||||
- `cli.api.error` — properties: `{ endpoint, status, error_code }`
|
||||
|
||||
Telemetry is **opt-out**. First run shows a one-line notice: "claudemesh collects anonymized usage telemetry. Disable with `claudemesh telemetry off`."
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Better Auth `apiKey` plugin version** — confirm it's installed and at a version that supports `enableMetadata`. Check `pnpm why better-auth` in `apps/web`.
|
||||
2. **Audit log table** — does one already exist? If not, this spec adds three rows of log; not worth a new table for that. Use `console.log` with structured JSON to stderr and let the platform's log collector handle it.
|
||||
3. **Email sending** — `claudemesh invite --email` requires a transactional email path. Does the web app already have one (Resend, Postmark)? If yes, reuse. If no, defer the email send to a follow-up; the invite command can still create the invite and print the URL.
|
||||
4. **Token scopes** — v1 ships with no scopes; every token has full account access. Should we add `mesh:read`, `mesh:write`, `invite:create` scopes from day one, or wait? **Recommendation:** wait. YAGNI. Add when a user actually wants a read-only CI token.
|
||||
5. **PAT expiry default** — 90 days? 1 year? Never? Better Auth supports all three. **Recommendation:** 1 year default, user can pick "never" with explicit warning.
|
||||
6. **Mesh slug uniqueness in `claudemesh create`** — what happens if two users try to create meshes with the same slug? Existing API behavior should be tested. If it errors, the CLI should suggest `--slug platform-team-2`.
|
||||
7. **`claudemesh login` when already logged in** — re-authenticate (overwrite) or error ("already logged in, run logout first")? **Recommendation:** re-authenticate silently with a one-line notice ("Replacing existing session for Alejandro").
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
For v0.11.0 to ship, all of these must be true:
|
||||
|
||||
- [ ] `claudemesh login` on a fresh machine (no `auth.json`) opens browser, completes device-code flow, writes `auth.json`, runs in <30 seconds end-to-end
|
||||
- [ ] `claudemesh login --token cm_pat_…` works without browser
|
||||
- [ ] `claudemesh logout` revokes server-side and deletes local file
|
||||
- [ ] `claudemesh whoami` prints user identity and token source
|
||||
- [ ] `claudemesh create "Test mesh"` creates a real mesh on the server, joins it locally, and the user can see it on the dashboard
|
||||
- [ ] `claudemesh invite --email a@b.c --mesh test` creates an invite and prints the URL
|
||||
- [ ] `claudemesh launch` (bare) on a fresh machine walks login → mesh picker → name/role → Claude Code, all in one wizard
|
||||
- [ ] Dashboard `/dashboard/settings/cli-tokens` lists, creates, and revokes PATs
|
||||
- [ ] All flows work in `en` and `es`
|
||||
- [ ] Existing `claudemesh launch` invocations (with token already in `auth.json`) still work without prompting
|
||||
- [ ] Token in `auth.json` survives an hour of idle and continues to work (no aggressive expiry)
|
||||
- [ ] Revoking a token in the dashboard makes the next CLI call fail with a clear error
|
||||
- [ ] Documentation updated in `README.md`, `apps/cli/README.md`, `docs/quickstart.md`
|
||||
- [ ] CHANGELOG entry written
|
||||
- [ ] Published to npm as `claudemesh-cli@0.11.0`
|
||||
|
||||
## What this unlocks
|
||||
|
||||
Once this lands, every dashboard-only feature becomes one CLI command away. Future specs that depend on this:
|
||||
|
||||
- `claudemesh members list` / `claudemesh members add`
|
||||
- `claudemesh billing usage`
|
||||
- `claudemesh mesh archive`
|
||||
- `claudemesh stream subscribe` (live broker events)
|
||||
- `claudemesh skill publish` (publish a skill to mesh registry)
|
||||
- `claudemesh log tail` (mesh-wide audit log)
|
||||
|
||||
This is the foundational unlock. Everything else is incremental on top.
|
||||
1490
.artifacts/specs/2026-04-10-cli-v2-pass2-facade-pattern.md
Normal file
1610
.artifacts/specs/2026-04-10-cli-v2-pass2-final-vision.md
Normal file
2060
.artifacts/specs/2026-04-10-cli-v2-pass2-local-first-storage.md
Normal file
1481
.artifacts/specs/2026-04-10-cli-v2-pass2-shared-infrastructure.md
Normal file
1702
.artifacts/specs/2026-04-10-cli-v2-pass2-ux-design.md
Normal file
1157
.artifacts/specs/2026-04-11-cli-v2-pass1.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Feature request draft: rich `<channel>` notification UI
|
||||
|
||||
**Target:** `anthropics/claude-code` GitHub issues / feedback channel.
|
||||
**Drafted:** 2026-04-15.
|
||||
|
||||
Paste the section below once the issue template is ready. Adjust tone
|
||||
to match Claude Code's issue style.
|
||||
|
||||
---
|
||||
|
||||
### Title
|
||||
|
||||
Rich UI for `notifications/claude/channel` messages (first-class chat, not just reminders)
|
||||
|
||||
### Body
|
||||
|
||||
**Summary**
|
||||
|
||||
MCP servers can emit `notifications/claude/channel` notifications which
|
||||
Claude Code renders inside the current turn as a `<channel>` reminder.
|
||||
For MCP servers that are conversational in nature (peer messaging,
|
||||
collaborative sessions, delegated agents), rendering these inline as
|
||||
plain-text reminders misses the UX affordances users expect from chat:
|
||||
|
||||
- sender avatar / identity
|
||||
- timestamp
|
||||
- priority badge (urgent / normal / low)
|
||||
- expandable quote from the original thread
|
||||
- optional inline reply action that calls a specific MCP tool
|
||||
|
||||
**Concrete use case**
|
||||
|
||||
[claudemesh](https://claudemesh.com) is a peer mesh for Claude Code
|
||||
sessions. When a peer sends a message it arrives as
|
||||
`notifications/claude/channel` with structured metadata in `meta`:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "notifications/claude/channel",
|
||||
"params": {
|
||||
"content": "alice: can you rebase main before deploy?",
|
||||
"meta": {
|
||||
"from_id": "<ed25519 hex>",
|
||||
"from_name": "alice",
|
||||
"priority": "now",
|
||||
"sent_at": "2026-04-15T00:00:00Z",
|
||||
"mesh_slug": "team-platform",
|
||||
"kind": "direct"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Today this renders as a `<channel>` text block — useful, but the user
|
||||
can't tell at a glance that it's from another human.
|
||||
|
||||
**What we'd like**
|
||||
|
||||
A hint on the notification (e.g. `meta.display: "chat"`) that lets
|
||||
Claude Code render it as a chat bubble with the `from_name` as the
|
||||
speaker, priority visualised, and an optional "Reply" action bound to
|
||||
a declared MCP tool (`reply_tool_name`).
|
||||
|
||||
**Why users would benefit beyond claudemesh**
|
||||
|
||||
- Delegated agent frameworks can render sub-agent responses as chat
|
||||
- Live-pairing MCP servers get a proper UI without inventing their own
|
||||
- The existing `<channel>` fallback means older clients still see
|
||||
the same text — additive, not breaking
|
||||
|
||||
**Willing to contribute a PR** if the feature is on-roadmap.
|
||||
58
.artifacts/specs/2026-04-15-cli-distribution-pipeline.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# CLI Distribution Pipeline
|
||||
|
||||
## Status
|
||||
- Shell installer (`/install`): ✅ live, needs polish
|
||||
- Single-binary build script (`scripts/build-binaries.ts`): ✅ written, not wired to CI
|
||||
- GitHub Releases publish: ❌ not set up
|
||||
- Homebrew tap: ❌ not set up
|
||||
- winget manifest: ❌ not set up
|
||||
|
||||
## Shipped this session (alpha.28)
|
||||
- `bun build --compile` script at `apps/cli-v2/scripts/build-binaries.ts` produces
|
||||
`dist/bin/claudemesh-{darwin,linux,windows}-{x64,arm64}` locally.
|
||||
- `/install` updated to use the one-command `claudemesh <invite-url>` flow.
|
||||
- `claudemesh url-handler install` registers the `claudemesh://` scheme on the three OSes.
|
||||
|
||||
## What's missing
|
||||
|
||||
### 1. GitHub Actions to build + publish binaries
|
||||
```yaml
|
||||
# .github/workflows/release-binaries.yml
|
||||
on: { push: { tags: ['v*'] } }
|
||||
jobs:
|
||||
build:
|
||||
strategy: { matrix: { target: [darwin-x64, darwin-arm64, linux-x64, linux-arm64, windows-x64] } }
|
||||
steps:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: cd apps/cli-v2 && bun install --frozen-lockfile
|
||||
- run: cd apps/cli-v2 && bun run scripts/build-binaries.ts
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with: { files: apps/cli-v2/dist/bin/* }
|
||||
```
|
||||
|
||||
### 2. `/install` detects missing Node and downloads a binary
|
||||
Current `/install` requires Node 20+. Next iteration: detect absence, curl the
|
||||
right binary from GitHub Releases, drop it in `~/.claudemesh/bin/`, add to PATH.
|
||||
|
||||
### 3. Homebrew tap (`homebrew-claudemesh`)
|
||||
Separate repo with a formula that points at the GitHub Release artifact.
|
||||
Users: `brew install alezmad/claudemesh/claudemesh`. Auto-updated by the
|
||||
release workflow via `brew bump-formula-pr`.
|
||||
|
||||
### 4. winget manifest
|
||||
YAML in `microsoft/winget-pkgs` repo pointing at the Windows .exe.
|
||||
|
||||
### 5. Auto-update in-CLI
|
||||
Already have `showUpdateNotice`. Upgrade to offer `claudemesh upgrade` that
|
||||
re-runs `/install` OR downloads a new binary in place.
|
||||
|
||||
## Why this matters
|
||||
Current state: users need Node, npm, and patience. Goal state:
|
||||
```
|
||||
curl -fsSL claudemesh.com/install | sh
|
||||
```
|
||||
…and that's it, on any OS, with or without Node.
|
||||
|
||||
## Priority
|
||||
After tier-1 usability (done), this is the next biggest lever for adoption.
|
||||
Estimate: 1-2 days for full pipeline, mostly CI config + release testing.
|
||||
84
.artifacts/specs/2026-04-15-invite-v2-cli-migration.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Invite v2 — CLI migration (server-side already shipped)
|
||||
|
||||
## Current state
|
||||
|
||||
**Server-side (broker) — DEPLOYED**
|
||||
- `canonicalInviteV2` bytes format (crypto.ts)
|
||||
- `verifyInviteV2` signature check
|
||||
- `claimInviteV2Core` at `POST /invites/:code/claim`
|
||||
- `sealRootKeyToRecipient` using crypto_box_seal
|
||||
- Every v1 invite also stores `capability_v2` for cross-compat
|
||||
- Web route `/api/public/invites/:code/claim` proxies to broker
|
||||
|
||||
**Client-side (CLI) — NOT MIGRATED**
|
||||
The CLI still uses the v1 flow (`enrollWithBroker`) which reads
|
||||
`mesh_root_key` from the invite token's base64 payload. This means:
|
||||
- Long URL `/join/<token>` contains the root key
|
||||
- Short URL `/i/<code>` resolves to the long URL → still contains root key
|
||||
- Anyone who can read the URL (history, screenshot, mail archive) has the key
|
||||
|
||||
## The v2 CLI flow
|
||||
|
||||
```
|
||||
parseInviteLinkV2(url)
|
||||
→ short URL /i/<code>? GET /api/public/invite-code/:code
|
||||
→ returns `{ found, code, mesh_slug, broker_url, owner_pubkey,
|
||||
canonical_v2, expires_at, role }` (NO root_key)
|
||||
→ generate local x25519 keypair (curve25519)
|
||||
→ POST /invites/<code>/claim { recipient_x25519_pubkey, display_name }
|
||||
→ broker verifies capability_v2 signature
|
||||
→ broker seals mesh.root_key with crypto_box_seal(root_key, our_pubkey)
|
||||
→ returns { sealed_root_key, mesh_id, member_id, owner_pubkey, canonical_v2 }
|
||||
→ open sealed_root_key with our x25519 secret key
|
||||
→ store root_key in ~/.claudemesh/config.json.meshes[].rootKey
|
||||
(NOT in the invite link — it was never transmitted unsealed)
|
||||
→ upgrade enroll to use claim response instead of the /join endpoint
|
||||
```
|
||||
|
||||
## What needs to change in the CLI
|
||||
|
||||
1. **New file** `apps/cli/src/services/invite/parse-v2.ts`
|
||||
- Detect short URL, resolve via `/api/public/invite-code/:code`
|
||||
- Expect the API returns v2 shape (server already has this route; verify field names)
|
||||
- Generate x25519 keypair via libsodium
|
||||
- POST to claim endpoint
|
||||
- Unseal root_key
|
||||
|
||||
2. **Conditional in `parseInviteLink`**
|
||||
- If URL is short-form and broker supports v2, use the new path
|
||||
- Fall back to v1 for legacy long-form URLs in transit
|
||||
|
||||
3. **Config schema** already has `rootKey` per mesh — just write from
|
||||
unsealed bytes instead of from the token payload.
|
||||
|
||||
4. **Spec test** `tests/golden/invite-v2.test.ts`
|
||||
- Broker already has `claimInviteV2Core` tests; add a CLI-side
|
||||
end-to-end that hits a local broker and verifies the sealed key
|
||||
round-trips.
|
||||
|
||||
## Why it wasn't rushed in this session
|
||||
|
||||
Crypto code deserves review. The server-side v2 shipped weeks ago
|
||||
with its own testing and audit; the CLI migration needs the same
|
||||
rigor — at minimum, a test that proves the sealed key we unseal
|
||||
matches the root_key the broker had in its DB, verified against
|
||||
`canonical_v2` signature.
|
||||
|
||||
The current v1 flow is a known quantity (the root_key-in-URL risk
|
||||
is documented in the spec). Broker is already v2-ready so when the
|
||||
CLI migration lands, emails / links can immediately start using the
|
||||
claim-only short URL without a server deploy.
|
||||
|
||||
## Rollout plan
|
||||
|
||||
1. Ship CLI v2 path behind `CLAUDEMESH_INVITE_V2=1` env.
|
||||
2. Dogfood: new invites generated by `claudemesh share` use `/api/public/invite-code/:code` with v2-shape response that omits token; CLI resolves via claim.
|
||||
3. Verify with `claudemesh verify` safety numbers cross-check.
|
||||
4. After 2 weeks uneventful, flip default to v2.
|
||||
5. After 60 days, stop embedding root_key in long URLs entirely.
|
||||
6. v3 (future): short URL becomes the only form.
|
||||
|
||||
## Effort
|
||||
|
||||
~1 day of focused crypto + testing. Broker work is done; API work is
|
||||
done; CLI work is a new parse path + a new enroll path + a few tests.
|
||||
75
.artifacts/specs/2026-04-15-per-peer-capabilities.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Per-Peer Capabilities
|
||||
|
||||
## Goal
|
||||
Give mesh members fine-grained control over what peers can do to their
|
||||
session. Today: any mesh peer can send you any message; all messages get
|
||||
pushed as `<channel>` reminders. Users can't say "only @alice can send me
|
||||
messages," "read-only peers," or "@bob can broadcast but not DM."
|
||||
|
||||
## Current state
|
||||
- Mesh-level role: `admin` | `member` (only affects invite issuance)
|
||||
- No per-peer filter — every peer message is delivered
|
||||
- No per-peer read/write split (all peers have the same capabilities)
|
||||
|
||||
## Target capability model
|
||||
|
||||
| Capability | Meaning |
|
||||
|--------------|--------------------------------------------------------|
|
||||
| `read` | Peer appears in your list_peers, can see your summary |
|
||||
| `dm` | Peer can send you direct messages |
|
||||
| `broadcast` | Peer's group broadcasts reach you |
|
||||
| `state-read` | Peer can read shared state keys |
|
||||
| `state-write`| Peer can set shared state keys |
|
||||
| `file-read` | Peer can read files you've shared (already exists) |
|
||||
|
||||
## CLI surface
|
||||
```
|
||||
claudemesh grant @alice dm broadcast # allow direct + broadcast
|
||||
claudemesh grant @bob state-read # read-only
|
||||
claudemesh revoke @alice broadcast
|
||||
claudemesh grants # list current grants per peer
|
||||
claudemesh block @spammer # shorthand for revoke-all
|
||||
```
|
||||
|
||||
## Broker schema
|
||||
New column on `mesh_member`:
|
||||
```sql
|
||||
peer_grants jsonb DEFAULT '{}'::jsonb
|
||||
-- shape: { "<peer_pubkey_hex>": ["dm", "broadcast", ...] }
|
||||
```
|
||||
|
||||
Alternative (cleaner): separate `peer_grant` table keyed on
|
||||
`(member_id, target_pubkey)`.
|
||||
|
||||
## Enforcement point
|
||||
Broker's message router (`apps/broker/src/index.ts` — send flow).
|
||||
Before writing the encrypted message to the recipient's queue, check
|
||||
`recipient.peer_grants[sender_pubkey]` against message kind. Drop
|
||||
silently if disallowed (sender sees delivered, recipient sees nothing —
|
||||
matches Signal/iMessage block semantics).
|
||||
|
||||
## Defaults
|
||||
- Unknown peers: `read + dm` (matches current behavior — additive-safe rollout)
|
||||
- Existing members: grandfathered into `read + dm + broadcast + state-read`
|
||||
via a migration
|
||||
- `claudemesh profile --default-grants read dm` lets users change their own default
|
||||
|
||||
## UI
|
||||
- `claudemesh peers` renders a `[grants: dm,broadcast]` tag per peer
|
||||
- `claudemesh verify` gains a `--with-grants` flag that shows the grant set
|
||||
alongside the safety number (helps the "did I accidentally block them?" check)
|
||||
|
||||
## Crypto implications
|
||||
Grants are server-enforced metadata. Not capability tokens. A malicious
|
||||
broker could forward messages regardless — this is about UX trust (spam /
|
||||
noise control), not protocol security. The spec is clear about this.
|
||||
|
||||
## Migration plan
|
||||
1. Ship broker schema change (jsonb column, nullable, default `{}`).
|
||||
2. Ship `grant/revoke/grants/block` CLI commands against an unused column.
|
||||
3. Enable enforcement in broker behind a per-mesh feature flag.
|
||||
4. Flip on for all meshes.
|
||||
|
||||
## Priority
|
||||
Nice-to-have. The killer feature here is `block` — every mesh gets a bad
|
||||
actor eventually. Ship `block` first even if the full grant system is deferred.
|
||||
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"sessionId":"ae5dbe38-9c56-4d07-9fb6-a38cb8a250a6","pid":4612,"acquiredAt":1776217467441}
|
||||
22
.claude/settings.local.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Connected to mesh, setting up:*)",
|
||||
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Connected to mesh, setting up session:*)",
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(grep -r \"defineCommand\\\\|export const run\" /Users/agutierrez/Desktop/claudemesh/apps/cli/src/commands/*.ts)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Ready to help:*)",
|
||||
"Bash(pnpm publish:*)",
|
||||
"Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")",
|
||||
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Investigating dropped keystrokes in claudemesh launch:*)",
|
||||
"Read(//Users/agutierrez/.claude/**)",
|
||||
"Read(//private/tmp/**)",
|
||||
"Bash(timeout 3 node dist/index.js mcp)",
|
||||
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh Fixed ZodError in MCP notification handler:*)",
|
||||
"Bash(npm i:*)",
|
||||
"Bash(claudemesh --version)",
|
||||
"Bash(/Users/agutierrez/.claude/hooks/play-tts.sh:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
58
.claude/skills/integration-nextjs-app-router/SKILL.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: integration-nextjs-app-router
|
||||
description: PostHog integration for Next.js App Router applications
|
||||
metadata:
|
||||
author: PostHog
|
||||
version: 1.9.5
|
||||
---
|
||||
|
||||
# PostHog integration for Next.js App Router
|
||||
|
||||
This skill helps you add PostHog analytics to Next.js App Router applications.
|
||||
|
||||
## Workflow
|
||||
|
||||
Follow these steps in order to complete the integration:
|
||||
|
||||
1. `basic-integration-1.0-begin.md` - PostHog Setup - Begin ← **Start here**
|
||||
2. `basic-integration-1.1-edit.md` - PostHog Setup - Edit
|
||||
3. `basic-integration-1.2-revise.md` - PostHog Setup - Revise
|
||||
4. `basic-integration-1.3-conclude.md` - PostHog Setup - Conclusion
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/EXAMPLE.md` - Next.js App Router example project code
|
||||
- `references/next-js.md` - Next.js - docs
|
||||
- `references/identify-users.md` - Identify users - docs
|
||||
- `references/basic-integration-1.0-begin.md` - PostHog setup - begin
|
||||
- `references/basic-integration-1.1-edit.md` - PostHog setup - edit
|
||||
- `references/basic-integration-1.2-revise.md` - PostHog setup - revise
|
||||
- `references/basic-integration-1.3-conclude.md` - PostHog setup - conclusion
|
||||
|
||||
The example project shows the target implementation pattern. Consult the documentation for API details.
|
||||
|
||||
## Key principles
|
||||
|
||||
- **Environment variables**: Always use environment variables for PostHog keys. Never hardcode them.
|
||||
- **Minimal changes**: Add PostHog code alongside existing integrations. Don't replace or restructure existing code.
|
||||
- **Match the example**: Your implementation should follow the example project's patterns as closely as possible.
|
||||
|
||||
## Framework guidelines
|
||||
|
||||
- For Next.js 15.3+, initialize PostHog in instrumentation-client.ts for the simplest setup
|
||||
- For feature flags, use useFeatureFlagEnabled() or useFeatureFlagPayload() hooks - they handle loading states and external sync automatically
|
||||
- Add analytics capture in event handlers where user actions occur, NOT in useEffect reacting to state changes
|
||||
- Do NOT use useEffect for data transformation - calculate derived values during render instead
|
||||
- Do NOT use useEffect to respond to user events - put that logic in the event handler itself
|
||||
- Do NOT use useEffect to chain state updates - calculate all related updates together in the event handler
|
||||
- Do NOT use useEffect to notify parent components - call the parent callback alongside setState in the event handler
|
||||
- To reset component state when a prop changes, pass the prop as the component's key instead of using useEffect
|
||||
- useEffect is ONLY for synchronizing with external systems (non-React widgets, browser APIs, network subscriptions)
|
||||
|
||||
## Identifying users
|
||||
|
||||
Identify users during login and signup events. Refer to the example code and documentation for the correct identify pattern for this framework. If both frontend and backend code exist, pass the client-side session and distinct ID using `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` headers to maintain correlation.
|
||||
|
||||
## Error tracking
|
||||
|
||||
Add PostHog error tracking to relevant files, particularly around critical user flows and API boundaries.
|
||||
@@ -0,0 +1,706 @@
|
||||
# PostHog Next.js App Router Example Project
|
||||
|
||||
Repository: https://github.com/PostHog/context-mill
|
||||
Path: basics/next-app-router
|
||||
|
||||
---
|
||||
|
||||
## README.md
|
||||
|
||||
# PostHog Next.js app router example
|
||||
|
||||
This is a [Next.js](https://nextjs.org) App Router example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
|
||||
|
||||
## Features
|
||||
|
||||
- **Product analytics**: Track user events and behaviors
|
||||
- **Session replay**: Record and replay user sessions
|
||||
- **Error tracking**: Capture and track errors
|
||||
- **User authentication**: Demo login system with PostHog user identification
|
||||
- **Server-side & Client-side tracking**: Examples of both tracking methods
|
||||
- **Reverse proxy**: PostHog ingestion through Next.js rewrites
|
||||
|
||||
## Getting started
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. Configure environment variables
|
||||
|
||||
Create a `.env.local` file in the root directory:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
|
||||
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
```
|
||||
|
||||
Get your PostHog project token from your [PostHog project settings](https://app.posthog.com/project/settings).
|
||||
|
||||
### 3. Run the development server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the app.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ └── auth/
|
||||
│ │ └── login/
|
||||
│ │ └── route.ts # Login API with server-side tracking
|
||||
│ ├── burrito/
|
||||
│ │ └── page.tsx # Demo feature page with event tracking
|
||||
│ ├── profile/
|
||||
│ │ └── page.tsx # User profile with error tracking demo
|
||||
│ ├── layout.tsx # Root layout with providers
|
||||
│ ├── page.tsx # Home/Login page
|
||||
│ └── globals.css # Global styles
|
||||
├── components/
|
||||
│ └── Header.tsx # Navigation header with auth state
|
||||
├── contexts/
|
||||
│ └── AuthContext.tsx # Authentication context with PostHog integration
|
||||
└── lib/
|
||||
└── posthog-server.ts # Server-side PostHog client
|
||||
|
||||
instrumentation-client.ts # Client-side PostHog initialization
|
||||
```
|
||||
|
||||
## Key integration points
|
||||
|
||||
### Client-side initialization (instrumentation-client.ts)
|
||||
|
||||
```typescript
|
||||
import posthog from "posthog-js"
|
||||
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
|
||||
api_host: "/ingest",
|
||||
ui_host: "https://us.posthog.com",
|
||||
defaults: '2026-01-30',
|
||||
capture_exceptions: true,
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
});
|
||||
```
|
||||
|
||||
### User identification (AuthContext.tsx)
|
||||
|
||||
```typescript
|
||||
posthog.identify(username, {
|
||||
username: username,
|
||||
});
|
||||
```
|
||||
|
||||
### Event tracking (burrito/page.tsx)
|
||||
|
||||
```typescript
|
||||
posthog.capture('burrito_considered', {
|
||||
total_considerations: count,
|
||||
username: username,
|
||||
});
|
||||
```
|
||||
|
||||
### Error tracking (profile/page.tsx)
|
||||
|
||||
```typescript
|
||||
posthog.captureException(error);
|
||||
```
|
||||
|
||||
### Server-side tracking (app/api/auth/login/route.ts)
|
||||
|
||||
```typescript
|
||||
const posthog = getPostHogClient();
|
||||
posthog.capture({
|
||||
distinctId: username,
|
||||
event: 'server_login',
|
||||
properties: { ... }
|
||||
});
|
||||
```
|
||||
|
||||
## App router differences from pages router
|
||||
|
||||
This example uses Next.js App Router instead of Pages Router. Key differences:
|
||||
|
||||
1. **File-based routing**: Pages in `src/app/` instead of `src/pages/`
|
||||
2. **layout.tsx**: Root layout component wraps all pages
|
||||
3. **API Routes**: Located in `src/app/api/` with `route.ts` files
|
||||
4. **'use client'**: Client components need explicit directive
|
||||
5. **useRouter**: From `next/navigation` instead of `next/router`
|
||||
6. **Metadata**: Exported from layout/page instead of Head component
|
||||
7. **Server Components**: Components are server-side by default
|
||||
|
||||
## Learn more
|
||||
|
||||
- [PostHog Documentation](https://posthog.com/docs)
|
||||
- [Next.js App Router Documentation](https://nextjs.org/docs/app)
|
||||
- [PostHog Next.js Integration Guide](https://posthog.com/docs/libraries/next-js)
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new).
|
||||
|
||||
Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
---
|
||||
|
||||
## .env.example
|
||||
|
||||
```example
|
||||
# PostHog Configuration
|
||||
# Get your PostHog project token from: https://app.posthog.com/project/settings
|
||||
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token_here
|
||||
# NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## instrumentation-client.ts
|
||||
|
||||
```ts
|
||||
import posthog from "posthog-js"
|
||||
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
|
||||
api_host: "/ingest",
|
||||
ui_host: "https://us.posthog.com",
|
||||
// Include the defaults option as required by PostHog
|
||||
defaults: '2026-01-30',
|
||||
// Enables capturing unhandled exceptions via Error Tracking
|
||||
capture_exceptions: true,
|
||||
// Turn on debug in development mode
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
//IMPORTANT: Never combine this approach with other client-side PostHog initialization approaches, especially components like a PostHogProvider. instrumentation-client.ts is the correct solution for initializating client-side PostHog in Next.js 15.3+ apps.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## next.config.ts
|
||||
|
||||
```ts
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/ingest/static/:path*",
|
||||
destination: "https://us-assets.i.posthog.com/static/:path*",
|
||||
},
|
||||
{
|
||||
source: "/ingest/:path*",
|
||||
destination: "https://us.i.posthog.com/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
// This is required to support PostHog trailing slash API requests
|
||||
skipTrailingSlashRedirect: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/app/api/auth/login/route.ts
|
||||
|
||||
```ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPostHogClient } from '@/lib/posthog-server';
|
||||
|
||||
const users = new Map<string, { username: string; burritoConsiderations: number }>();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { username, password } = await request.json();
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'Username and password required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let user = users.get(username);
|
||||
const isNewUser = !user;
|
||||
|
||||
if (!user) {
|
||||
user = { username, burritoConsiderations: 0 };
|
||||
users.set(username, user);
|
||||
}
|
||||
|
||||
// Capture server-side login event
|
||||
const posthog = getPostHogClient();
|
||||
posthog.capture({
|
||||
distinctId: username,
|
||||
event: 'server_login',
|
||||
properties: {
|
||||
username: username,
|
||||
isNewUser: isNewUser,
|
||||
source: 'api'
|
||||
}
|
||||
});
|
||||
|
||||
// Identify user on server side
|
||||
posthog.identify({
|
||||
distinctId: username,
|
||||
properties: {
|
||||
username: username,
|
||||
createdAt: isNewUser ? new Date().toISOString() : undefined
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, user });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/app/burrito/page.tsx
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
export default function BurritoPage() {
|
||||
const { user, incrementBurritoConsiderations } = useAuth();
|
||||
const router = useRouter();
|
||||
const [hasConsidered, setHasConsidered] = useState(false);
|
||||
|
||||
// Redirect to home if not logged in
|
||||
if (!user) {
|
||||
router.push('/');
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleConsideration = () => {
|
||||
incrementBurritoConsiderations();
|
||||
setHasConsidered(true);
|
||||
setTimeout(() => setHasConsidered(false), 2000);
|
||||
|
||||
// Capture burrito consideration event
|
||||
posthog.capture('burrito_considered', {
|
||||
total_considerations: user.burritoConsiderations + 1,
|
||||
username: user.username,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Burrito consideration zone</h1>
|
||||
<p>Take a moment to truly consider the potential of burritos.</p>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button
|
||||
onClick={handleConsideration}
|
||||
className="btn-burrito"
|
||||
>
|
||||
I have considered the burrito potential
|
||||
</button>
|
||||
|
||||
{hasConsidered && (
|
||||
<p className="success">
|
||||
Thank you for your consideration! Count: {user.burritoConsiderations}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stats">
|
||||
<h3>Consideration stats</h3>
|
||||
<p>Total considerations: {user.burritoConsiderations}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/app/layout.tsx
|
||||
|
||||
```tsx
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import Header from "@/components/Header";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Burrito Consideration App",
|
||||
description: "Consider the potential of burritos",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/app/page.tsx
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
export default function Home() {
|
||||
const { user, login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const success = await login(username, password);
|
||||
if (success) {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
} else {
|
||||
setError('Please provide both username and password');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err);
|
||||
setError('An error occurred during login');
|
||||
}
|
||||
};
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Welcome back, {user.username}!</h1>
|
||||
<p>You are now logged in. Feel free to explore:</p>
|
||||
<ul>
|
||||
<li>Consider the potential of burritos</li>
|
||||
<li>View your profile and statistics</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Welcome to Burrito Consideration App</h1>
|
||||
<p>Please sign in to begin your burrito journey</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter any username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter any password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary">Sign In</button>
|
||||
</form>
|
||||
|
||||
<p className="note">
|
||||
Note: This is a demo app. Use any username and password to sign in.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/app/profile/page.tsx
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
// Redirect to home if not logged in
|
||||
if (!user) {
|
||||
router.push('/');
|
||||
return null;
|
||||
}
|
||||
|
||||
const triggerTestError = () => {
|
||||
try {
|
||||
throw new Error('Test error for PostHog error tracking');
|
||||
} catch (err) {
|
||||
posthog.captureException(err);
|
||||
console.error('Captured error:', err);
|
||||
alert('Error captured and sent to PostHog!');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>User Profile</h1>
|
||||
|
||||
<div className="stats">
|
||||
<h2>Your Information</h2>
|
||||
<p><strong>Username:</strong> {user.username}</p>
|
||||
<p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
|
||||
Trigger Test Error (for PostHog)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<h3>Your Burrito Journey</h3>
|
||||
{user.burritoConsiderations === 0 ? (
|
||||
<p>You haven't considered any burritos yet. Visit the Burrito Consideration page to start!</p>
|
||||
) : user.burritoConsiderations === 1 ? (
|
||||
<p>You've considered the burrito potential once. Keep going!</p>
|
||||
) : user.burritoConsiderations < 5 ? (
|
||||
<p>You're getting the hang of burrito consideration!</p>
|
||||
) : user.burritoConsiderations < 10 ? (
|
||||
<p>You're becoming a burrito consideration expert!</p>
|
||||
) : (
|
||||
<p>You are a true burrito consideration master! 🌯</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/components/Header.tsx
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-container">
|
||||
<nav>
|
||||
<Link href="/">Home</Link>
|
||||
{user && (
|
||||
<>
|
||||
<Link href="/burrito">Burrito Consideration</Link>
|
||||
<Link href="/profile">Profile</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="user-section">
|
||||
{user ? (
|
||||
<>
|
||||
<span>Welcome, {user.username}!</span>
|
||||
<button onClick={logout} className="btn-logout">
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>Not logged in</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/contexts/AuthContext.tsx
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
burritoConsiderations: number;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
incrementBurritoConsiderations: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const users: Map<string, User> = new Map();
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Use lazy initializer to read from localStorage only once on mount
|
||||
const [user, setUser] = useState<User | null>(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const storedUsername = localStorage.getItem('currentUser');
|
||||
if (storedUsername) {
|
||||
const existingUser = users.get(storedUsername);
|
||||
if (existingUser) {
|
||||
return existingUser;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { user: userData } = await response.json();
|
||||
|
||||
let localUser = users.get(username);
|
||||
if (!localUser) {
|
||||
localUser = userData as User;
|
||||
users.set(username, localUser);
|
||||
}
|
||||
|
||||
setUser(localUser);
|
||||
localStorage.setItem('currentUser', username);
|
||||
|
||||
// Identify user in PostHog using username as distinct ID
|
||||
posthog.identify(username, {
|
||||
username: username,
|
||||
});
|
||||
|
||||
// Capture login event
|
||||
posthog.capture('user_logged_in', {
|
||||
username: username,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
// Capture logout event before resetting
|
||||
posthog.capture('user_logged_out');
|
||||
posthog.reset();
|
||||
|
||||
setUser(null);
|
||||
localStorage.removeItem('currentUser');
|
||||
};
|
||||
|
||||
const incrementBurritoConsiderations = () => {
|
||||
if (user) {
|
||||
user.burritoConsiderations++;
|
||||
users.set(user.username, user);
|
||||
setUser({ ...user });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## src/lib/posthog-server.ts
|
||||
|
||||
```ts
|
||||
import { PostHog } from 'posthog-node';
|
||||
|
||||
let posthogClient: PostHog | null = null;
|
||||
|
||||
export function getPostHogClient() {
|
||||
if (!posthogClient) {
|
||||
posthogClient = new PostHog(
|
||||
process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!,
|
||||
{
|
||||
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
flushAt: 1,
|
||||
flushInterval: 0
|
||||
}
|
||||
);
|
||||
posthogClient.debug(true);
|
||||
}
|
||||
return posthogClient;
|
||||
}
|
||||
|
||||
export async function shutdownPostHog() {
|
||||
if (posthogClient) {
|
||||
await posthogClient.shutdown();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: PostHog Setup - Begin
|
||||
description: Start the event tracking setup process by analyzing the project and creating an event tracking plan
|
||||
---
|
||||
|
||||
We're making an event tracking plan for this project.
|
||||
|
||||
Before proceeding, find any existing `posthog.capture()` code. Make note of event name formatting.
|
||||
|
||||
From the project's file list, select between 10 and 15 files that might have interesting business value for event tracking, especially conversion and churn events. Also look for additional files related to login that could be used for identifying users, along with error handling. Read the files. If a file is already well-covered by PostHog events, replace it with another option. Do not spawn subagents.
|
||||
|
||||
Look for opportunities to track client-side events.
|
||||
|
||||
**IMPORTANT: Server-side events are REQUIRED** if the project includes any instrumentable server-side code. If the project has API routes (e.g., `app/api/**/route.ts`) or Server Actions, you MUST include server-side events for critical business operations like:
|
||||
|
||||
- Payment/checkout completion
|
||||
- Webhook handlers
|
||||
- Authentication endpoints
|
||||
|
||||
Do not skip server-side events - they capture actions that cannot be tracked client-side.
|
||||
|
||||
Create a new file with a JSON array at the root of the project: .posthog-events.json. It should include one object for each event we want to add: event name, event description, and the file path we want to place the event in. If events already exist, don't duplicate them; supplement them.
|
||||
|
||||
Track actions only, not pageviews. These can be captured automatically. Exceptions can be made for "viewed"-type events that correspond to the top of a conversion funnel.
|
||||
|
||||
As you review files, make an internal note of opportunities to identify users and catch errors. We'll need them for the next step.
|
||||
|
||||
## Status
|
||||
|
||||
Before beginning a phase of the setup, you will send a status message with the exact prefix '[STATUS]', as in:
|
||||
|
||||
[STATUS] Checking project structure.
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Checking project structure
|
||||
- Verifying PostHog dependencies
|
||||
- Generating events based on project
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Upon completion, continue with:** [basic-integration-1.1-edit.md](basic-integration-1.1-edit.md)
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: PostHog Setup - Edit
|
||||
description: Implement PostHog event tracking in the identified files, following best practices and the example project
|
||||
---
|
||||
|
||||
For each of the files and events noted in .posthog-events.json, make edits to capture events using PostHog. Make sure to set up any helper files needed. Carefully examine the included example project code: your implementation should match it as closely as possible. Do not spawn subagents.
|
||||
|
||||
Use environment variables for PostHog keys. Do not hardcode PostHog keys.
|
||||
|
||||
If a file already has existing integration code for other tools or services, don't overwrite or remove that code. Place PostHog code below it.
|
||||
|
||||
For each event, add useful properties, and use your access to the PostHog source code to ensure correctness. You also have access to documentation about creating new events with PostHog. Consider this documentation carefully and follow it closely before adding events. Your integration should be based on documented best practices. Carefully consider how the user project's framework version may impact the correct PostHog integration approach.
|
||||
|
||||
Remember that you can find the source code for any dependency in the node_modules directory. This may be necessary to properly populate property names. There are also example project code files available via the PostHog MCP; use these for reference.
|
||||
|
||||
Where possible, add calls for PostHog's identify() function on the client side upon events like logins and signups. Use the contents of login and signup forms to identify users on submit. If there is server-side code, pass the client-side session and distinct ID to the server-side code to identify the user. On the server side, make sure events have a matching distinct ID where relevant.
|
||||
|
||||
It's essential to do this in both client code and server code, so that user behavior from both domains is easy to correlate.
|
||||
|
||||
You should also add PostHog exception capture error tracking to these files where relevant.
|
||||
|
||||
Remember: Do not alter the fundamental architecture of existing files. Make your additions minimal and targeted.
|
||||
|
||||
Remember the documentation and example project resources you were provided at the beginning. Read them now.
|
||||
|
||||
## Status
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Inserting PostHog capture code
|
||||
- A status message for each file whose edits you are planning, including a high level summary of changes
|
||||
- A status message for each file you have edited
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Upon completion, continue with:** [basic-integration-1.2-revise.md](basic-integration-1.2-revise.md)
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: PostHog Setup - Revise
|
||||
description: Review and fix any errors in the PostHog integration implementation
|
||||
---
|
||||
|
||||
Check the project for errors. Read the package.json file for any type checking or build scripts that may provide input about what to fix. Remember that you can find the source code for any dependency in the node_modules directory. Do not spawn subagents.
|
||||
|
||||
Ensure that any components created were actually used.
|
||||
|
||||
Once all other tasks are complete, run any linter or prettier-like scripts found in the package.json, but ONLY on the files you have edited or created during this session. Do not run formatting or linting across the entire project's codebase.
|
||||
|
||||
## Status
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Finding and correcting errors
|
||||
- Report details of any errors you fix
|
||||
- Linting, building and prettying
|
||||
|
||||
---
|
||||
|
||||
**Upon completion, continue with:** [basic-integration-1.3-conclude.md](basic-integration-1.3-conclude.md)
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: PostHog Setup - Conclusion
|
||||
description: Review and fix any errors in the PostHog integration implementation
|
||||
---
|
||||
|
||||
Use the PostHog MCP to create a new dashboard named "Analytics basics" based on the events created here. Make sure to use the exact same event names as implemented in the code. Populate it with up to five insights, with special emphasis on things like conversion funnels, churn events, and other business critical insights.
|
||||
|
||||
Search for a file called `.posthog-events.json` and read it for available events. Do not spawn subagents.
|
||||
|
||||
Create the file posthog-setup-report.md. It should include a summary of the integration edits, a table with the event names, event descriptions, and files where events were added, along with a list of links for the dashboard and insights created. Follow this format:
|
||||
|
||||
<wizard-report>
|
||||
# PostHog post-wizard report
|
||||
|
||||
The wizard has completed a deep integration of your project. [Detailed summary of changes]
|
||||
|
||||
[table of events/descriptions/files]
|
||||
|
||||
## Next steps
|
||||
|
||||
We've built some insights and a dashboard for you to keep an eye on user behavior, based on the events we just instrumented:
|
||||
|
||||
[links]
|
||||
|
||||
### Agent skill
|
||||
|
||||
We've left an agent skill folder in your project. You can use this context for further agent development when using Claude Code. This will help ensure the model provides the most up-to-date approaches for integrating PostHog.
|
||||
|
||||
</wizard-report>
|
||||
|
||||
Upon completion, remove .posthog-events.json.
|
||||
|
||||
## Status
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Configured dashboard: [insert PostHog dashboard URL]
|
||||
- Created setup report: [insert full local file path]
|
||||
@@ -0,0 +1,202 @@
|
||||
# Identify users - Docs
|
||||
|
||||
Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.
|
||||
|
||||
This is straightforward to do when [capturing backend events](/docs/product-analytics/capture-events?tab=Node.js.md), as you associate events to a specific user using a `distinct_id`, which is a required argument.
|
||||
|
||||
However, in the frontend of a [web](/docs/libraries/js/features.md#capturing-events) or [mobile app](/docs/libraries/ios.md#capturing-events), a `distinct_id` is not a required argument — PostHog's SDKs will generate an anonymous `distinct_id` for you automatically and you can capture events anonymously, provided you use the appropriate [configuration](/docs/libraries/js/features.md#capturing-anonymous-events).
|
||||
|
||||
To link events to specific users, call `identify`:
|
||||
|
||||
PostHog AI
|
||||
|
||||
### Web
|
||||
|
||||
```javascript
|
||||
posthog.identify(
|
||||
'distinct_id', // Replace 'distinct_id' with your user's unique identifier
|
||||
{ email: 'max@hedgehogmail.com', name: 'Max Hedgehog' } // optional: set additional person properties
|
||||
);
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
```kotlin
|
||||
PostHog.identify(
|
||||
distinctId = distinctID, // Replace 'distinctID' with your user's unique identifier
|
||||
// optional: set additional person properties
|
||||
userProperties = mapOf(
|
||||
"name" to "Max Hedgehog",
|
||||
"email" to "max@hedgehogmail.com"
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
```swift
|
||||
PostHogSDK.shared.identify("distinct_id", // Replace "distinct_id" with your user's unique identifier
|
||||
userProperties: ["name": "Max Hedgehog", "email": "max@hedgehogmail.com"]) // optional: set additional person properties
|
||||
```
|
||||
|
||||
### React Native
|
||||
|
||||
```jsx
|
||||
posthog.identify('distinct_id', { // Replace "distinct_id" with your user's unique identifier
|
||||
email: 'max@hedgehogmail.com', // optional: set additional person properties
|
||||
name: 'Max Hedgehog'
|
||||
})
|
||||
```
|
||||
|
||||
### Dart
|
||||
|
||||
```dart
|
||||
await Posthog().identify(
|
||||
userId: 'distinct_id', // Replace "distinct_id" with your user's unique identifier
|
||||
userProperties: {
|
||||
email: "max@hedgehogmail.com", // optional: set additional person properties
|
||||
name: "Max Hedgehog"
|
||||
});
|
||||
```
|
||||
|
||||
Events captured after calling `identify` are identified events and this creates a person profile if one doesn't exist already.
|
||||
|
||||
Due to the cost of processing them, anonymous events can be up to 4x cheaper than identified events, so it's recommended you only capture identified events when needed.
|
||||
|
||||
## How identify works
|
||||
|
||||
When a user starts browsing your website or app, PostHog automatically assigns them an **anonymous ID**, which is stored locally.
|
||||
|
||||
Provided you've [configured persistence](/docs/libraries/js/persistence.md) to use cookies or `localStorage`, this enables us to track anonymous users – even across different sessions.
|
||||
|
||||
By calling `identify` with a `distinct_id` of your choice (usually the user's ID in your database, or their email), you link the anonymous ID and distinct ID together.
|
||||
|
||||
Thus, all past and future events made with that anonymous ID are now associated with the distinct ID.
|
||||
|
||||
This enables you to do things like associate events with a user from before they log in for the first time, or associate their events across different devices or platforms.
|
||||
|
||||
Using identify in the backend
|
||||
|
||||
Although you can call `identify` using our backend SDKs, it is used most in frontends. This is because there is no concept of anonymous sessions in the backend SDKs, so calling `identify` only updates person profiles.
|
||||
|
||||
## Best practices when using `identify`
|
||||
|
||||
### 1\. Call `identify` as soon as you're able to
|
||||
|
||||
In your frontend, you should call `identify` as soon as you're able to.
|
||||
|
||||
Typically, this is every time your **app loads** for the first time, and directly after your **users log in**.
|
||||
|
||||
This ensures that events sent during your users' sessions are correctly associated with them.
|
||||
|
||||
You only need to call `identify` once per session, and you should avoid calling it multiple times unnecessarily.
|
||||
|
||||
If you call `identify` multiple times with the same data without reloading the page in between, PostHog will ignore the subsequent calls.
|
||||
|
||||
### 2\. Use unique strings for distinct IDs
|
||||
|
||||
If two users have the same distinct ID, their data is merged and they are considered one user in PostHog. Two common ways this can happen are:
|
||||
|
||||
- Your logic for generating IDs does not generate sufficiently strong IDs and you can end up with a clash where 2 users have the same ID.
|
||||
- There's a bug, typo, or mistake in your code leading to most or all users being identified with generic IDs like `null`, `true`, or `distinctId`.
|
||||
|
||||
PostHog also has built-in protections to stop the most common distinct ID mistakes.
|
||||
|
||||
### 3\. Reset after logout
|
||||
|
||||
If a user logs out on your frontend, you should call `reset()` to unlink any future events made on that device with that user.
|
||||
|
||||
This is important if your users are sharing a computer, as otherwise all of those users are grouped together into a single user due to shared cookies between sessions.
|
||||
|
||||
**We strongly recommend you call `reset` on logout even if you don't expect users to share a computer.**
|
||||
|
||||
You can do that like so:
|
||||
|
||||
PostHog AI
|
||||
|
||||
### Web
|
||||
|
||||
```javascript
|
||||
posthog.reset()
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
```swift
|
||||
PostHogSDK.shared.reset()
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
```kotlin
|
||||
PostHog.reset()
|
||||
```
|
||||
|
||||
### React Native
|
||||
|
||||
```jsx
|
||||
posthog.reset()
|
||||
```
|
||||
|
||||
### Dart
|
||||
|
||||
```dart
|
||||
Posthog().reset()
|
||||
```
|
||||
|
||||
If you *also* want to reset the `device_id` so that the device will be considered a new device in future events, you can pass `true` as an argument:
|
||||
|
||||
Web
|
||||
|
||||
PostHog AI
|
||||
|
||||
```javascript
|
||||
posthog.reset(true)
|
||||
```
|
||||
|
||||
### 4\. Person profiles and properties
|
||||
|
||||
You'll notice that one of the parameters in the `identify` method is a `properties` object.
|
||||
|
||||
This enables you to set [person properties](/docs/product-analytics/person-properties.md).
|
||||
|
||||
Whenever possible, we recommend passing in all person properties you have available each time you call identify, as this ensures their person profile on PostHog is up to date.
|
||||
|
||||
Person properties can also be set being adding a `$set` property to a event `capture` call.
|
||||
|
||||
See our [person properties docs](/docs/product-analytics/person-properties.md) for more details on how to work with them and best practices.
|
||||
|
||||
### 5\. Use deep links between platforms
|
||||
|
||||
We recommend you call `identify` [as soon as you're able](#1-call-identify-as-soon-as-youre-able), typically when a user signs up or logs in.
|
||||
|
||||
This doesn't work if one or both platforms are unauthenticated. Some examples of such cases are:
|
||||
|
||||
- Onboarding and signup flows before authentication.
|
||||
- Unauthenticated web pages redirecting to authenticated mobile apps.
|
||||
- Authenticated web apps prompting an app download.
|
||||
|
||||
In these cases, you can use a [deep link](https://developer.android.com/training/app-links/deep-linking) on Android and [universal links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app) on iOS to identify users.
|
||||
|
||||
1. Use `posthog.get_distinct_id()` to get the current distinct ID. Even if you cannot call identify because the user is unauthenticated, this will return an anonymous distinct ID generated by PostHog.
|
||||
2. Add the distinct ID to the deep link as query parameters, along with other properties like UTM parameters.
|
||||
3. When the user is redirected to the app, parse the deep link and handle the following cases:
|
||||
|
||||
- The user is already authenticated on the mobile app. In this case, call [`posthog.alias()`](/docs/libraries/js/features.md#alias) with the distinct ID from the web. This associates the two distinct IDs as a single person.
|
||||
- The user is unauthenticated. In this case, call [`posthog.identify()`](/docs/libraries/js/features.md#identifying-users) with the distinct ID from the web. Events will be associated with this distinct ID.
|
||||
|
||||
As long as you associate the distinct IDs with `posthog.identify()` or `posthog.alias()`, you can track events generated across platforms.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Identifying users docs](/docs/product-analytics/identify.md)
|
||||
- [How person processing works](/docs/how-posthog-works/ingestion-pipeline.md#2-person-processing)
|
||||
- [An introductory guide to identifying users in PostHog](/tutorials/identifying-users-guide.md)
|
||||
|
||||
### Community questions
|
||||
|
||||
Ask a question
|
||||
|
||||
### Was this page useful?
|
||||
|
||||
HelpfulCould be better
|
||||
@@ -0,0 +1,385 @@
|
||||
# Next.js - Docs
|
||||
|
||||
PostHog makes it easy to get data about traffic and usage of your [Next.js](https://nextjs.org/) app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.
|
||||
|
||||
This guide walks you through integrating PostHog into your Next.js app using the [React](/docs/libraries/react.md) and the [Node.js](/docs/libraries/node.md) SDKs.
|
||||
|
||||
> You can see a working example of this integration in our [Next.js demo app](https://github.com/PostHog/posthog-js/tree/main/playground/nextjs).
|
||||
|
||||
Next.js has both client and server-side rendering, as well as pages and app routers. We'll cover all of these options in this guide.
|
||||
|
||||
> **Try `@posthog/next` (pre-release):** A simplified Next.js integration with synchronized client/server identity, server-side flag bootstrapping, and a built-in API proxy. [Read the setup guide →](/docs/libraries/next-js/posthog-next.md)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
To follow this guide along, you need:
|
||||
|
||||
1. A PostHog instance (either [Cloud](https://app.posthog.com/signup) or [self-hosted](/docs/self-host.md))
|
||||
2. A Next.js application
|
||||
|
||||
## Beta: integration via LLM
|
||||
|
||||
Install PostHog for Next.js in seconds with our wizard by running this prompt with [LLM coding agents](/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.
|
||||
|
||||
`npx @posthog/wizard@latest`
|
||||
|
||||
[Learn more](/wizard.md)
|
||||
|
||||
Or, to integrate manually, continue with the rest of this guide.
|
||||
|
||||
## Client-side setup
|
||||
|
||||
Install `posthog-js` using your package manager:
|
||||
|
||||
PostHog AI
|
||||
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install --save posthog-js
|
||||
```
|
||||
|
||||
### Yarn
|
||||
|
||||
```bash
|
||||
yarn add posthog-js
|
||||
```
|
||||
|
||||
### pnpm
|
||||
|
||||
```bash
|
||||
pnpm add posthog-js
|
||||
```
|
||||
|
||||
### Bun
|
||||
|
||||
```bash
|
||||
bun add posthog-js
|
||||
```
|
||||
|
||||
Add your environment variables to your `.env.local` file and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token in your [project settings](https://app.posthog.com/project/settings).
|
||||
|
||||
.env.local
|
||||
|
||||
PostHog AI
|
||||
|
||||
```shell
|
||||
NEXT_PUBLIC_POSTHOG_TOKEN=<ph_project_token>
|
||||
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
```
|
||||
|
||||
These values need to start with `NEXT_PUBLIC_` to be accessible on the client-side.
|
||||
|
||||
## Integration
|
||||
|
||||
Next.js provides the [`instrumentation-client.ts|js`](https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client) file for client-side setup. Add it to the root of your Next.js app (for both app and pages router) and initialize PostHog in it like this:
|
||||
|
||||
PostHog AI
|
||||
|
||||
### instrumentation-client.js
|
||||
|
||||
```javascript
|
||||
import posthog from 'posthog-js'
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
|
||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
defaults: '2026-01-30'
|
||||
});
|
||||
```
|
||||
|
||||
### instrumentation-client.ts
|
||||
|
||||
```typescript
|
||||
import posthog from 'posthog-js'
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN!, {
|
||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
defaults: '2026-01-30'
|
||||
});
|
||||
```
|
||||
|
||||
Bootstrapping with `instrumentation-client`
|
||||
|
||||
When using `instrumentation-client`, the values you pass to `posthog.init` remain fixed for the entire session. This means bootstrapping only works if you evaluate flags **before your app renders** (for example, on the server).
|
||||
|
||||
If you need flag values after the app has rendered, you’ll want to:
|
||||
|
||||
- Evaluate the flag on the server and pass the value into your app, or
|
||||
- Evaluate the flag in an earlier page/state, then store and re-use it when needed.
|
||||
|
||||
Both approaches avoid flicker and give you the same outcome as bootstrapping, as long as you use the same `distinct_id` across client and server.
|
||||
|
||||
See the [bootstrapping guide](/docs/feature-flags/bootstrapping.md) for more information.
|
||||
|
||||
## Identifying users
|
||||
|
||||
> **Identifying users is required.** Call `posthog.identify('your-user-id')` after login to link events to a known user. This is what connects frontend event captures, [session replays](/docs/session-replay.md), [LLM traces](/docs/ai-engineering.md), and [error tracking](/docs/error-tracking.md) to the same person — and lets backend events link back too.
|
||||
>
|
||||
> See our guide on [identifying users](/docs/getting-started/identify-users.md) for how to set this up.
|
||||
|
||||
Set up a reverse proxy (recommended)
|
||||
|
||||
We recommend [setting up a reverse proxy](/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
|
||||
|
||||
We have our [own managed reverse proxy service](/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
|
||||
|
||||
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using [Cloudflare](/docs/advanced/proxy/cloudflare.md), [AWS Cloudfront](/docs/advanced/proxy/cloudfront.md), and [Vercel](/docs/advanced/proxy/vercel.md).
|
||||
|
||||
Grouping products in one project (recommended)
|
||||
|
||||
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and [group them in one project](/docs/settings/projects.md).
|
||||
|
||||
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
|
||||
|
||||
Add IPs to Firewall/WAF allowlists (recommended)
|
||||
|
||||
For certain features like [heatmaps](/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHog’s requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
|
||||
|
||||
**EU**: `3.75.65.221`, `18.197.246.42`, `3.120.223.253`
|
||||
|
||||
**US**: `44.205.89.55`, `52.4.194.122`, `44.208.188.173`
|
||||
|
||||
These are public, stable IPs used by PostHog services (e.g., Celery tasks for snapshots).
|
||||
|
||||
## Accessing PostHog
|
||||
|
||||
Once initialized in `instrumentation-client.js|ts`, import `posthog` from `posthog-js` anywhere and call the methods you need on the `posthog` object.
|
||||
|
||||
JavaScript
|
||||
|
||||
PostHog AI
|
||||
|
||||
```javascript
|
||||
'use client'
|
||||
import posthog from 'posthog-js'
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => posthog.capture('test_event')}>
|
||||
Click me for an event
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using React hooks
|
||||
|
||||
The [React feature flag hooks](/docs/libraries/react.md#feature-flags) work automatically when PostHog is initialized via `instrumentation-client.ts`. The hooks use the initialized posthog-js singleton:
|
||||
|
||||
JavaScript
|
||||
|
||||
PostHog AI
|
||||
|
||||
```javascript
|
||||
'use client'
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
export default function FeatureComponent() {
|
||||
const showNewFeature = useFeatureFlagEnabled('new-feature')
|
||||
return showNewFeature ? <NewFeature /> : <OldFeature />
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
See the [React SDK docs](/docs/libraries/react.md) for examples of how to use:
|
||||
|
||||
- [`posthog-js` functions like custom event capture, user identification, and more.](/docs/libraries/react.md#using-posthog-js-functions)
|
||||
- [Feature flags including variants and payloads.](/docs/libraries/react.md#feature-flags)
|
||||
|
||||
You can also read [the full `posthog-js` documentation](/docs/libraries/js/features.md) for all the usable functions.
|
||||
|
||||
## Server-side analytics
|
||||
|
||||
Next.js enables you to both server-side render pages and add server-side functionality. To integrate PostHog into your Next.js app on the server-side, you can use the [Node SDK](/docs/libraries/node.md).
|
||||
|
||||
First, install the `posthog-node` library:
|
||||
|
||||
PostHog AI
|
||||
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install posthog-node --save
|
||||
```
|
||||
|
||||
### Yarn
|
||||
|
||||
```bash
|
||||
yarn add posthog-node
|
||||
```
|
||||
|
||||
### pnpm
|
||||
|
||||
```bash
|
||||
pnpm add posthog-node
|
||||
```
|
||||
|
||||
### Bun
|
||||
|
||||
```bash
|
||||
bun add posthog-node
|
||||
```
|
||||
|
||||
### Router-specific instructions
|
||||
|
||||
## App router
|
||||
|
||||
For the app router, we can initialize the `posthog-node` SDK once with a `PostHogClient` function, and import it into files.
|
||||
|
||||
This enables us to send events and fetch data from PostHog on the server – without making client-side requests.
|
||||
|
||||
JavaScript
|
||||
|
||||
PostHog AI
|
||||
|
||||
```javascript
|
||||
// app/posthog.js
|
||||
import { PostHog } from 'posthog-node'
|
||||
export default function PostHogClient() {
|
||||
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
|
||||
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
flushAt: 1,
|
||||
flushInterval: 0
|
||||
})
|
||||
return posthogClient
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** Because server-side functions in Next.js can be short-lived, we set `flushAt` to `1` and `flushInterval` to `0`.
|
||||
>
|
||||
> - `flushAt` sets how many capture calls we should flush the queue (in one batch).
|
||||
> - `flushInterval` sets how many milliseconds we should wait before flushing the queue. Setting them to the lowest number ensures events are sent immediately and not batched. We also need to call `await posthog.shutdown()` once done.
|
||||
|
||||
To use this client, we import it into our pages and call it with the `PostHogClient` function:
|
||||
|
||||
JavaScript
|
||||
|
||||
PostHog AI
|
||||
|
||||
```javascript
|
||||
import Link from 'next/link'
|
||||
import PostHogClient from '../posthog'
|
||||
export default async function About() {
|
||||
const posthog = PostHogClient()
|
||||
const flags = await posthog.getAllFlags(
|
||||
'user_distinct_id' // replace with a user's distinct ID
|
||||
);
|
||||
await posthog.shutdown()
|
||||
return (
|
||||
<main>
|
||||
<h1>About</h1>
|
||||
<Link href="/">Go home</Link>
|
||||
{ flags['main-cta'] &&
|
||||
<Link href="http://posthog.com/">Go to PostHog</Link>
|
||||
}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Pages router
|
||||
|
||||
For the pages router, we can use the `getServerSideProps` function to access PostHog on the server-side, send events, evaluate feature flags, and more.
|
||||
|
||||
This looks like this:
|
||||
|
||||
JavaScript
|
||||
|
||||
PostHog AI
|
||||
|
||||
```javascript
|
||||
// pages/posts/[id].js
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { PostHog } from 'posthog-node'
|
||||
export default function Post({ post, flags }) {
|
||||
const [ctaState, setCtaState] = useState()
|
||||
useEffect(() => {
|
||||
if (flags) {
|
||||
setCtaState(flags['blog-cta'])
|
||||
}
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<h1>{post.title}</h1>
|
||||
<p>By: {post.author}</p>
|
||||
<p>{post.content}</p>
|
||||
{ctaState &&
|
||||
<p><a href="/">Go to PostHog</a></p>
|
||||
}
|
||||
<button onClick={likePost}>Like</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export async function getServerSideProps(ctx) {
|
||||
const session = await getServerSession(ctx.req, ctx.res)
|
||||
let flags = null
|
||||
if (session) {
|
||||
const client = new PostHog(
|
||||
process.env.NEXT_PUBLIC_POSTHOG_TOKEN,
|
||||
{
|
||||
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
}
|
||||
)
|
||||
flags = await client.getAllFlags(session.user.email);
|
||||
client.capture({
|
||||
distinctId: session.user.email,
|
||||
event: 'loaded blog article',
|
||||
properties: {
|
||||
$current_url: ctx.req.url,
|
||||
},
|
||||
});
|
||||
await client.shutdown()
|
||||
}
|
||||
const { posts } = await import('../../blog.json')
|
||||
const post = posts.find((post) => post.id.toString() === ctx.params.id)
|
||||
return {
|
||||
props: {
|
||||
post,
|
||||
flags
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: Make sure to *always* call `await client.shutdown()` after sending events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.
|
||||
|
||||
### Server-side configuration
|
||||
|
||||
Next.js overrides the default `fetch` behavior on the server to introduce their own cache. PostHog ignores that cache by default, as this is Next.js's default behavior for any fetch call.
|
||||
|
||||
You can override that configuration when initializing PostHog, but make sure you understand the pros/cons of using Next.js's cache and that you might get cached results rather than the actual result our server would return. This is important for feature flags, for example.
|
||||
|
||||
TSX
|
||||
|
||||
PostHog AI
|
||||
|
||||
```jsx
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
|
||||
// ... your configuration
|
||||
fetch_options: {
|
||||
cache: 'force-cache', // Use Next.js cache
|
||||
next_options: { // Passed to the `next` option for `fetch`
|
||||
revalidate: 60, // Cache for 60 seconds
|
||||
tags: ['posthog'], // Can be used with Next.js `revalidateTag` function
|
||||
},
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Configuring a reverse proxy to PostHog
|
||||
|
||||
To improve the reliability of client-side tracking and make requests less likely to be intercepted by tracking blockers, you can setup a reverse proxy in Next.js. Read more about deploying a reverse proxy using [Next.js rewrites](/docs/advanced/proxy/nextjs.md), [Next.js middleware](/docs/advanced/proxy/nextjs-middleware.md), and [Vercel rewrites](/docs/advanced/proxy/vercel.md).
|
||||
|
||||
## Further reading
|
||||
|
||||
- [How to set up Next.js analytics, feature flags, and more](/tutorials/nextjs-analytics.md)
|
||||
- [How to set up Next.js pages router analytics, feature flags, and more](/tutorials/nextjs-pages-analytics.md)
|
||||
- [How to set up Next.js A/B tests](/tutorials/nextjs-ab-tests.md)
|
||||
|
||||
### Community questions
|
||||
|
||||
Ask a question
|
||||
|
||||
### Was this page useful?
|
||||
|
||||
HelpfulCould be better
|
||||
@@ -16,3 +16,6 @@ URL="http://localhost:3000"
|
||||
|
||||
# Default locale of the apps, can be overridden separately in each app.
|
||||
DEFAULT_LOCALE="en"
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match between broker and web app
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
|
||||
115
.github/workflows/release-cli.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
name: Release CLI binaries
|
||||
|
||||
# Fires on any push of a tag shaped like `cli-v1.2.3` (prerelease `-alpha.N` OK).
|
||||
# Builds self-contained `bun build --compile` binaries for darwin/linux/win
|
||||
# (x64 + arm64) and attaches them to a GitHub Release. The `install.sh`
|
||||
# fallback path curls these when Node isn't available.
|
||||
#
|
||||
# Publishing to npm is still a manual step (pnpm publish from apps/cli) —
|
||||
# this workflow only handles binary distribution.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "cli-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to build (e.g. cli-v1.0.0-alpha.28)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write # to upload release assets
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { target: darwin-x64, bun_target: bun-darwin-x64, runner: macos-latest, ext: "" }
|
||||
- { target: darwin-arm64, bun_target: bun-darwin-arm64, runner: macos-latest, ext: "" }
|
||||
- { target: linux-x64, bun_target: bun-linux-x64, runner: ubuntu-latest, ext: "" }
|
||||
- { target: linux-arm64, bun_target: bun-linux-arm64, runner: ubuntu-latest, ext: "" }
|
||||
- { target: windows-x64, bun_target: bun-windows-x64, runner: windows-latest, ext: ".exe" }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.2"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install workspace deps
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Compile binary
|
||||
working-directory: apps/cli
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist/bin
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
bun build --compile --minify \
|
||||
--target=${{ matrix.bun_target }} \
|
||||
--define "__CLAUDEMESH_VERSION__=\"$VERSION\"" \
|
||||
src/entrypoints/cli.ts \
|
||||
--outfile dist/bin/claudemesh-${{ matrix.target }}${{ matrix.ext }}
|
||||
|
||||
# Smoke test only on native arch. macos-latest runners are ARM64 (Apple
|
||||
# Silicon); ubuntu-latest is x64. Cross-compiled binaries can't execute
|
||||
# on the build host, so skip them.
|
||||
- name: Smoke test (native only)
|
||||
if: matrix.target == 'darwin-arm64' || matrix.target == 'linux-x64'
|
||||
working-directory: apps/cli
|
||||
run: |
|
||||
./dist/bin/claudemesh-${{ matrix.target }} --version
|
||||
./dist/bin/claudemesh-${{ matrix.target }} --help | head -5
|
||||
|
||||
- name: Upload artefact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claudemesh-${{ matrix.target }}
|
||||
path: apps/cli/dist/bin/claudemesh-${{ matrix.target }}${{ matrix.ext }}
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Stage binaries
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f -exec cp {} release/ \;
|
||||
cd release && sha256sum claudemesh-* > SHA256SUMS
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
release/claudemesh-*
|
||||
release/SHA256SUMS
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
update-homebrew:
|
||||
needs: release
|
||||
runs-on: macos-latest
|
||||
if: github.event_name == 'push' && !contains(github.ref_name, 'alpha')
|
||||
steps:
|
||||
- name: Bump Homebrew tap formula
|
||||
env:
|
||||
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
brew tap alezmad/claudemesh || true
|
||||
brew bump-formula-pr --no-browse --no-fork \
|
||||
--tag "${{ github.ref_name }}" \
|
||||
--revision "${{ github.sha }}" \
|
||||
alezmad/claudemesh/claudemesh || echo "formula bump skipped (no tap yet)"
|
||||
9
.github/workflows/tests.yml
vendored
@@ -45,3 +45,12 @@ jobs:
|
||||
|
||||
- name: 🧪 Test
|
||||
run: pnpm run test
|
||||
|
||||
- name: 📦 Build CLI bundle (check size budget)
|
||||
working-directory: apps/cli
|
||||
run: pnpm run build
|
||||
|
||||
- name: 🔧 CLI smoke — --version + --help
|
||||
run: |
|
||||
node apps/cli/dist/entrypoints/cli.js --version
|
||||
node apps/cli/dist/entrypoints/cli.js --help | head -5
|
||||
|
||||
4
.gitignore
vendored
@@ -45,6 +45,9 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# secrets
|
||||
.cli_sync_secret
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -72,3 +75,4 @@ dist/
|
||||
apps/web/payload.db
|
||||
apps/web/public/media/*
|
||||
!apps/web/public/media/.gitkeep
|
||||
.env.local
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"geminiApiKey": "AIzaSyBblLRkmypvabqI-xJ_b2KPVA9Pswtav0M"
|
||||
"geminiApiKey": "AIzaSyDJEyW5Q_OT1X4iGO_5jdVnq1BNANR7s2k"
|
||||
}
|
||||
34
CLAUDE.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# claudemesh
|
||||
|
||||
Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
|
||||
|
||||
## Structure
|
||||
|
||||
- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws`. Runs drizzle migrations on startup under pg_advisory_lock.
|
||||
- `apps/cli/` — `claudemesh-cli` npm package (CLI + MCP server). Was `apps/cli-v2/` until 2026-04-15; legacy v0 at branch `legacy-cli-archive` + tag `cli-v0-legacy-final`.
|
||||
- `apps/web/` — Marketing site + dashboard at claudemesh.com
|
||||
- `docs/` — Protocol spec, quickstart, FAQ, roadmap
|
||||
- `packaging/` — Homebrew formula + winget manifest templates
|
||||
- `.github/workflows/release-cli.yml` — tag `cli-v*` → 5 platform binaries → GitHub Release with SHA256SUMS
|
||||
|
||||
## Key docs
|
||||
|
||||
- `SPEC.md` — What claudemesh is, protocol, crypto, wire format
|
||||
- `docs/protocol.md` — Wire protocol reference
|
||||
- `docs/roadmap.md` — Public roadmap (shipped + planned)
|
||||
- `docs/vision-20260407.md` — Internal feature brainstorm with 19 ideas across 3 tiers, effort estimates, and build order
|
||||
|
||||
## Deploy
|
||||
|
||||
- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`. Pending migrations apply automatically on startup.
|
||||
- **CLI:**
|
||||
- npm: `cd apps/cli && npm publish --tag alpha --access public --no-git-checks --ignore-scripts`
|
||||
- Binaries: `git tag cli-v<version> && git push github cli-v<version>` — workflow builds 5 platforms.
|
||||
- **Web:** Vercel auto-deploy on push to GitHub
|
||||
|
||||
## Dev
|
||||
|
||||
- Monorepo: pnpm workspaces + Turborepo
|
||||
- Broker dev: `cd apps/broker && bun --hot src/index.ts`
|
||||
- CLI build: `cd apps/cli && pnpm build` (Bun bundler)
|
||||
- CLI link for local testing: `cd apps/cli && npm link`
|
||||
124
SPEC.md
@@ -855,7 +855,63 @@ The broker:
|
||||
|
||||
---
|
||||
|
||||
## 13. Encryption
|
||||
## 13. Claude Code Integration — How Push Delivery Works
|
||||
|
||||
Understanding how Claude Code processes channel notifications is critical for claudemesh reliability.
|
||||
|
||||
### The notification pipeline
|
||||
|
||||
```
|
||||
MCP server (claudemesh-cli)
|
||||
└─ server.notification("notifications/claude/channel", { content, meta })
|
||||
└─ writes JSON-RPC to stdout
|
||||
└─ Claude Code reads from MCP process stdout
|
||||
└─ setNotificationHandler fires
|
||||
└─ enqueue({ mode: "prompt", value: wrappedContent, origin: { kind: "channel" } })
|
||||
└─ React useSyncExternalStore triggers re-render
|
||||
└─ useQueueProcessor effect fires
|
||||
└─ processQueueIfReady() → executeInput()
|
||||
└─ Claude sees ← claudemesh: ...
|
||||
```
|
||||
|
||||
### Key requirements (from Claude Code source)
|
||||
|
||||
1. **Feature gate**: `feature('KAIROS') || feature('KAIROS_CHANNELS')` must be true. `KAIROS_CHANNELS` is external (GrowthBook). `--dangerously-load-development-channels` sets `entry.dev = true` which bypasses the allowlist check but still requires the feature gate.
|
||||
|
||||
2. **OAuth auth required**: Channel notifications require `claude.ai` authentication (OAuth tokens). API key users are blocked. This means `claude login --for-claude-ai` must have been run.
|
||||
|
||||
3. **Server name must match**: The MCP server's declared name (`new Server({ name: "claudemesh" })`) must match the channel entry from `--dangerously-load-development-channels server:claudemesh`.
|
||||
|
||||
4. **Meta keys**: Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`. No hyphens. All values must be strings.
|
||||
|
||||
5. **Capability declaration**: Server must declare `experimental: { "claude/channel": {} }` in capabilities.
|
||||
|
||||
6. **Queue processing is event-driven**: `enqueue()` triggers a React store update → `useEffect` fires → processes immediately. No polling needed on the Claude Code side. The 1s poll timer in claudemesh is for draining the WS push buffer into notifications — Claude Code handles the rest instantly.
|
||||
|
||||
### Priority gating on the broker
|
||||
|
||||
The broker holds `"next"` and `"low"` priority messages when the peer's status is `"working"`. Only `"now"` messages deliver immediately regardless of status. This is by design — but can cause perceived "push not working" when the hook reports `working` status.
|
||||
|
||||
```
|
||||
Status: idle → delivers: now, next, low
|
||||
Status: working → delivers: now only
|
||||
Status: dnd → delivers: now only
|
||||
```
|
||||
|
||||
If a peer appears to not receive messages, check their status in `list_peers`. A peer stuck in `"working"` (e.g., stale hook) will only receive `"now"` priority messages.
|
||||
|
||||
### Common issues
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---------|-------------|
|
||||
| Messages never arrive | Session started before CLI update — restart with `claudemesh launch` |
|
||||
| Messages arrive with 5+ minute delay | Peer status stuck on `"working"` — `next` messages held until idle |
|
||||
| `← claudemesh:` never appears in idle session | Feature gate `KAIROS_CHANNELS` not enabled, or not OAuth-authenticated |
|
||||
| Messages arrive only on `check_messages` | Channel handler not registered — check `--dangerously-load-development-channels` flag |
|
||||
|
||||
---
|
||||
|
||||
## 14. Encryption
|
||||
|
||||
### Direct messages
|
||||
|
||||
@@ -875,6 +931,72 @@ The session keypair generates once on first connect and survives reconnects. Mes
|
||||
|
||||
---
|
||||
|
||||
## 14b. Invites (v2 protocol)
|
||||
|
||||
### Why v2
|
||||
|
||||
The v1 invite token embeds `mesh_root_key` (32-byte shared secret) inside a base64url URL. Any path that caches URLs — link previews, browser history, sync, screenshots, analytics pixels, error logs — is a permanent compromise of the mesh key. Revoking the invite does not rotate the key. The URL *is* the secret.
|
||||
|
||||
v2 removes all secret material from the URL. The invite becomes a short opaque code that grants the *right* to receive the key, not the key itself. The server only releases the key after the recipient proves they can receive it, sealed to a public key the recipient controls.
|
||||
|
||||
### Canonical bytes
|
||||
|
||||
The mesh owner ed25519 secret key signs:
|
||||
|
||||
```
|
||||
v=2|mesh_id|invite_id|expires_at_unix|role|owner_pubkey_hex
|
||||
```
|
||||
|
||||
No `root_key`, no `broker_url`. The signed capability lives in the broker DB. The user-visible URL is `claudemesh.com/i/{code}` — base62, 8 chars.
|
||||
|
||||
### Claim flow
|
||||
|
||||
```
|
||||
1. Admin mints invite
|
||||
broker stores {id, mesh_id, code, role, max_uses, expires_at,
|
||||
signed_capability, version=2}
|
||||
returns claudemesh.com/i/{code}
|
||||
|
||||
2. Recipient lands on /i/{code}
|
||||
web resolves the code, shows consent: mesh name, inviter, role,
|
||||
expiry, member count. No secrets in the response.
|
||||
|
||||
3. Recipient generates a fresh x25519 keypair
|
||||
(separate from its ed25519 identity — distinct curve, distinct use)
|
||||
|
||||
4. Recipient POSTs its x25519 public key
|
||||
POST /api/public/invites/{code}/claim
|
||||
body: { recipient_x25519_pubkey }
|
||||
|
||||
5. Broker validates and seals
|
||||
verifies signed_capability against mesh.owner_pubkey
|
||||
checks expires_at, max_uses vs used_count, revoked_at
|
||||
creates mesh.member row, increments used_count
|
||||
sealed_root_key = crypto_box_seal(root_key, recipient_x25519_pubkey)
|
||||
returns { sealed_root_key, mesh_id, member_id, owner_pubkey,
|
||||
canonical_v2 }
|
||||
|
||||
6. Recipient unseals with its x25519 secret
|
||||
root_key = crypto_box_seal_open(sealed_root_key, recipient_x25519_sk)
|
||||
joins normal mesh traffic
|
||||
```
|
||||
|
||||
The server never sees the recipient's private key. `crypto_box_seal` is anonymous — no sender identity, no interaction beyond the single HTTP round trip.
|
||||
|
||||
### v1 deprecation timeline
|
||||
|
||||
- v0.1.x: the broker, CLI, and web accept both v1 (long token with embedded key) and v2 (short code + sealed key delivery). New invites default to v2.
|
||||
- v0.2.0: v1 endpoints return `410 Gone`. Existing members already in a mesh are unaffected — the key rotation story is orthogonal to invite format.
|
||||
|
||||
### DB additions
|
||||
|
||||
- `mesh.invite.version` int default 1
|
||||
- `mesh.invite.capability_v2` text nullable — the canonical signed bytes
|
||||
- `mesh.invite.claimed_by_pubkey` text nullable — the recipient x25519 pubkey used at claim time (audit trail, single-use enforcement)
|
||||
- `mesh.pending_invite` new table for email invites: `{id, meshId, email, code, sentAt, acceptedAt, revokedAt, createdBy, createdAt}`. Email delivery goes through Postmark (already wired via turbostarter).
|
||||
|
||||
---
|
||||
|
||||
## 14. Production hardening (implemented)
|
||||
|
||||
| Feature | Description |
|
||||
|
||||
@@ -35,9 +35,13 @@ ENV BROKER_PORT=7900
|
||||
|
||||
COPY --from=deps --chown=bun:bun /deploy /app
|
||||
|
||||
# Copy migrations folder alongside the broker so runtime auto-migrate
|
||||
# has files to apply. Workspace deploy subset drops them otherwise.
|
||||
COPY --from=deps --chown=bun:bun /app/packages/db/migrations /app/migrations
|
||||
|
||||
EXPOSE 7900
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
||||
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
# Non-root user (oven/bun image ships with 'bun' uid 1000)
|
||||
|
||||
@@ -15,13 +15,20 @@
|
||||
},
|
||||
"prettier": "@turbostarter/prettier-config",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@qdrant/js-client-rest": "1.17.0",
|
||||
"@react-email/components": "0.3.2",
|
||||
"@react-email/render": "1.3.2",
|
||||
"@turbostarter/db": "workspace:*",
|
||||
"@turbostarter/shared": "workspace:*",
|
||||
"drizzle-orm": "0.44.7",
|
||||
"grammy": "^1.35.0",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"minio": "8.0.7",
|
||||
"neo4j-driver": "6.0.1",
|
||||
"postgres": "3.4.5",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"ws": "8.20.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
@@ -31,6 +38,8 @@
|
||||
"@turbostarter/tsconfig": "workspace:*",
|
||||
"@turbostarter/vitest-config": "workspace:*",
|
||||
"@types/libsodium-wrappers": "0.7.14",
|
||||
"@types/react": "19.2.0",
|
||||
"@types/react-dom": "19.2.0",
|
||||
"@types/ws": "8.5.13",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
|
||||
215
apps/broker/src/audit.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Signed audit log with hash-chain integrity.
|
||||
*
|
||||
* Every significant mesh event is recorded as an append-only entry.
|
||||
* Each entry's SHA-256 hash includes the previous entry's hash,
|
||||
* forming a tamper-evident chain per mesh. If any row is modified
|
||||
* or deleted, all subsequent hashes will fail verification.
|
||||
*
|
||||
* NEVER logs message content (ciphertext or plaintext) — only metadata.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { asc, desc, eq, sql, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { auditLog } from "@turbostarter/db/schema/mesh";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory last-hash cache (one entry per mesh, loaded from DB on startup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lastHash = new Map<string, string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core audit logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeHash(
|
||||
prevHash: string,
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
createdAt: Date,
|
||||
): string {
|
||||
const input = `${prevHash}|${meshId}|${eventType}|${actorMemberId}|${JSON.stringify(payload)}|${createdAt.toISOString()}`;
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an audit entry for a mesh event.
|
||||
*
|
||||
* Fire-and-forget safe — callers should `void audit(...)` or
|
||||
* `.catch(log.warn)` to avoid blocking the hot path.
|
||||
*/
|
||||
export async function audit(
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
actorDisplayName: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const prevHash = lastHash.get(meshId) ?? "genesis";
|
||||
const createdAt = new Date();
|
||||
const hash = computeHash(prevHash, meshId, eventType, actorMemberId, payload, createdAt);
|
||||
|
||||
try {
|
||||
await db.insert(auditLog).values({
|
||||
meshId,
|
||||
eventType,
|
||||
actorMemberId,
|
||||
actorDisplayName,
|
||||
payload,
|
||||
prevHash,
|
||||
hash,
|
||||
createdAt,
|
||||
});
|
||||
lastHash.set(meshId, hash);
|
||||
} catch (e) {
|
||||
log.warn("audit log insert failed", {
|
||||
mesh_id: meshId,
|
||||
event_type: eventType,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup: load last hash per mesh from DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function loadLastHashes(): Promise<void> {
|
||||
try {
|
||||
// For each mesh, find the most recent audit entry by id (serial).
|
||||
// DISTINCT ON (mesh_id) ORDER BY id DESC gives us one row per mesh.
|
||||
const rows = await db.execute<{ mesh_id: string; hash: string }>(sql`
|
||||
SELECT DISTINCT ON (mesh_id) mesh_id, hash
|
||||
FROM mesh.audit_log
|
||||
ORDER BY mesh_id, id DESC
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
lastHash.set(row.mesh_id, row.hash);
|
||||
}
|
||||
log.info("audit: loaded last hashes", { meshes: lastHash.size });
|
||||
} catch (e) {
|
||||
// Table may not exist yet on first boot — that's fine.
|
||||
log.warn("audit: loadLastHashes failed (table may not exist yet)", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function verifyChain(
|
||||
meshId: string,
|
||||
): Promise<{ valid: boolean; entries: number; brokenAt?: number }> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.meshId, meshId))
|
||||
.orderBy(asc(auditLog.id));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { valid: true, entries: 0 };
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const expectedPrevHash = i === 0 ? "genesis" : rows[i - 1]!.hash;
|
||||
|
||||
// Verify prevHash linkage
|
||||
if (row.prevHash !== expectedPrevHash) {
|
||||
return { valid: false, entries: rows.length, brokenAt: row.id };
|
||||
}
|
||||
|
||||
// Recompute hash and verify
|
||||
const recomputed = computeHash(
|
||||
row.prevHash,
|
||||
row.meshId,
|
||||
row.eventType,
|
||||
row.actorMemberId,
|
||||
row.payload as Record<string, unknown>,
|
||||
row.createdAt,
|
||||
);
|
||||
if (recomputed !== row.hash) {
|
||||
return { valid: false, entries: rows.length, brokenAt: row.id };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, entries: rows.length };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: paginated audit entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function queryAuditLog(
|
||||
meshId: string,
|
||||
options?: { limit?: number; offset?: number; eventType?: string },
|
||||
): Promise<{ entries: Array<{ id: number; eventType: string; actor: string; payload: Record<string, unknown>; hash: string; createdAt: string }>; total: number }> {
|
||||
const limit = options?.limit ?? 50;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
const conditions = [eq(auditLog.meshId, meshId)];
|
||||
if (options?.eventType) {
|
||||
conditions.push(eq(auditLog.eventType, options.eventType));
|
||||
}
|
||||
const where = conditions.length === 1 ? conditions[0]! : and(...conditions);
|
||||
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(where)
|
||||
.orderBy(desc(auditLog.id))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(auditLog)
|
||||
.where(where),
|
||||
]);
|
||||
|
||||
return {
|
||||
entries: rows.map((r) => ({
|
||||
id: r.id,
|
||||
eventType: r.eventType,
|
||||
actor: r.actorDisplayName ?? r.actorMemberId ?? "system",
|
||||
payload: r.payload as Record<string, unknown>,
|
||||
hash: r.hash,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
total: Number(countResult[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ensure table exists (raw DDL for first-boot before migrations run)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function ensureAuditLogTable(): Promise<void> {
|
||||
try {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS mesh.audit_log (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_member_id TEXT,
|
||||
actor_display_name TEXT,
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
} catch (e) {
|
||||
log.warn("audit: ensureAuditLogTable failed", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
68
apps/broker/src/broker-crypto.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Broker-side symmetric encryption for persisting resolved env vars.
|
||||
*
|
||||
* Uses Node's built-in crypto (AES-256-GCM). The key comes from
|
||||
* BROKER_ENCRYPTION_KEY env var (64 hex chars = 32 bytes). If not set,
|
||||
* a random key is generated and logged on first use — operator should
|
||||
* persist it to survive broker restarts.
|
||||
*
|
||||
* This is NOT the same as peer-side E2E crypto (libsodium). This is
|
||||
* platform-level encryption-at-rest, same model as Heroku/Coolify/AWS.
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { env } from "./env";
|
||||
import { log } from "./logger";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
let _key: Buffer | null = null;
|
||||
|
||||
function getKey(): Buffer {
|
||||
if (_key) return _key;
|
||||
|
||||
if (env.BROKER_ENCRYPTION_KEY && env.BROKER_ENCRYPTION_KEY.length === 64) {
|
||||
_key = Buffer.from(env.BROKER_ENCRYPTION_KEY, "hex");
|
||||
} else {
|
||||
_key = randomBytes(32);
|
||||
log.warn("BROKER_ENCRYPTION_KEY not set — generated ephemeral key. " +
|
||||
"Set BROKER_ENCRYPTION_KEY=" + _key.toString("hex") + " to persist across restarts.");
|
||||
}
|
||||
return _key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a JSON-serializable value. Returns a base64 string containing
|
||||
* IV + ciphertext + auth tag.
|
||||
*/
|
||||
export function encryptForStorage(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Pack: IV (12) + tag (16) + ciphertext
|
||||
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value produced by encryptForStorage. Returns the plaintext
|
||||
* string, or null if decryption fails (wrong key, tampered).
|
||||
*/
|
||||
export function decryptFromStorage(packed: string): string | null {
|
||||
try {
|
||||
const key = getKey();
|
||||
const buf = Buffer.from(packed, "base64");
|
||||
const iv = buf.subarray(0, IV_LEN);
|
||||
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ciphertext = buf.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = createDecipheriv(ALGO, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,15 @@ import {
|
||||
mesh,
|
||||
meshFile,
|
||||
meshFileAccess,
|
||||
meshFileKey,
|
||||
meshContext,
|
||||
meshMember as memberTable,
|
||||
meshMemory,
|
||||
meshState,
|
||||
meshService,
|
||||
meshSkill,
|
||||
meshStream,
|
||||
meshVaultEntry,
|
||||
meshTask,
|
||||
messageQueue,
|
||||
pendingStatus,
|
||||
@@ -395,6 +399,7 @@ export async function listPeersInMesh(
|
||||
summary: string | null;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
connectedAt: Date;
|
||||
}>
|
||||
> {
|
||||
@@ -408,6 +413,7 @@ export async function listPeersInMesh(
|
||||
summary: presence.summary,
|
||||
groups: presence.groups,
|
||||
sessionId: presence.sessionId,
|
||||
cwd: presence.cwd,
|
||||
connectedAt: presence.connectedAt,
|
||||
})
|
||||
.from(presence)
|
||||
@@ -427,6 +433,7 @@ export async function listPeersInMesh(
|
||||
summary: r.summary,
|
||||
groups: (r.groups ?? []) as Array<{ name: string; role?: string }>,
|
||||
sessionId: r.sessionId,
|
||||
cwd: r.cwd,
|
||||
connectedAt: r.connectedAt,
|
||||
}));
|
||||
}
|
||||
@@ -700,6 +707,182 @@ export async function forgetMemory(
|
||||
);
|
||||
}
|
||||
|
||||
// --- Skills ---
|
||||
|
||||
/**
|
||||
* Upsert a skill in a mesh. If a skill with the same name exists, it is updated.
|
||||
*/
|
||||
export async function shareSkill(
|
||||
meshId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
instructions: string,
|
||||
tags: string[],
|
||||
memberId?: string,
|
||||
memberName?: string,
|
||||
manifest?: unknown,
|
||||
): Promise<string> {
|
||||
const existing = await db
|
||||
.select({ id: meshSkill.id })
|
||||
.from(meshSkill)
|
||||
.where(and(eq(meshSkill.meshId, meshId), eq(meshSkill.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(meshSkill)
|
||||
.set({
|
||||
description,
|
||||
instructions,
|
||||
tags,
|
||||
manifest: manifest ?? null,
|
||||
authorMemberId: memberId ?? null,
|
||||
authorName: memberName ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(meshSkill.id, existing[0]!.id));
|
||||
return existing[0]!.id;
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(meshSkill)
|
||||
.values({
|
||||
meshId,
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
tags,
|
||||
manifest: manifest ?? null,
|
||||
authorMemberId: memberId ?? null,
|
||||
authorName: memberName ?? null,
|
||||
})
|
||||
.returning({ id: meshSkill.id });
|
||||
if (!row) throw new Error("failed to insert skill");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a skill by name in a mesh.
|
||||
*/
|
||||
export async function getSkill(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): Promise<{
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
manifest: unknown;
|
||||
createdAt: Date;
|
||||
} | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
instructions: meshSkill.instructions,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
manifest: meshSkill.manifest,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(and(eq(meshSkill.meshId, meshId), eq(meshSkill.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
const r = rows[0]!;
|
||||
return {
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
instructions: r.instructions,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
manifest: r.manifest,
|
||||
createdAt: r.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List skills in a mesh, optionally filtering by keyword across name, description, and tags.
|
||||
*/
|
||||
export async function listSkills(
|
||||
meshId: string,
|
||||
query?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: Date;
|
||||
}>
|
||||
> {
|
||||
if (query) {
|
||||
const pattern = `%${query}%`;
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(
|
||||
and(
|
||||
eq(meshSkill.meshId, meshId),
|
||||
or(
|
||||
sql`${meshSkill.name} ILIKE ${pattern}`,
|
||||
sql`${meshSkill.description} ILIKE ${pattern}`,
|
||||
sql`EXISTS (SELECT 1 FROM unnest(${meshSkill.tags}) AS t WHERE t ILIKE ${pattern})`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(meshSkill.name));
|
||||
return rows.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(eq(meshSkill.meshId, meshId))
|
||||
.orderBy(asc(meshSkill.name));
|
||||
return rows.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a skill by name in a mesh. Returns true if a row was deleted.
|
||||
*/
|
||||
export async function removeSkill(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.delete(meshSkill)
|
||||
.where(and(eq(meshSkill.meshId, meshId), eq(meshSkill.name, name)))
|
||||
.returning({ id: meshSkill.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
// --- File sharing ---
|
||||
|
||||
/**
|
||||
@@ -717,6 +900,8 @@ export async function uploadFile(args: {
|
||||
uploadedByMember?: string;
|
||||
targetSpec?: string;
|
||||
expiresAt?: Date;
|
||||
encrypted?: boolean;
|
||||
ownerPubkey?: string;
|
||||
}): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(meshFile)
|
||||
@@ -732,6 +917,8 @@ export async function uploadFile(args: {
|
||||
uploadedByMember: args.uploadedByMember ?? null,
|
||||
targetSpec: args.targetSpec ?? null,
|
||||
expiresAt: args.expiresAt ?? null,
|
||||
encrypted: args.encrypted ?? false,
|
||||
ownerPubkey: args.ownerPubkey ?? null,
|
||||
})
|
||||
.returning({ id: meshFile.id });
|
||||
if (!row) throw new Error("failed to insert file row");
|
||||
@@ -755,6 +942,8 @@ export async function getFile(
|
||||
uploadedByName: string | null;
|
||||
targetSpec: string | null;
|
||||
uploadedAt: Date;
|
||||
encrypted: boolean;
|
||||
ownerPubkey: string | null;
|
||||
} | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
@@ -768,6 +957,8 @@ export async function getFile(
|
||||
uploadedByName: meshFile.uploadedByName,
|
||||
targetSpec: meshFile.targetSpec,
|
||||
uploadedAt: meshFile.uploadedAt,
|
||||
encrypted: meshFile.encrypted,
|
||||
ownerPubkey: meshFile.ownerPubkey,
|
||||
})
|
||||
.from(meshFile)
|
||||
.where(
|
||||
@@ -782,6 +973,8 @@ export async function getFile(
|
||||
return {
|
||||
...row,
|
||||
tags: (row.tags ?? []) as string[],
|
||||
encrypted: row.encrypted,
|
||||
ownerPubkey: row.ownerPubkey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -801,6 +994,7 @@ export async function listFiles(
|
||||
uploadedBy: string;
|
||||
uploadedAt: Date;
|
||||
persistent: boolean;
|
||||
encrypted: boolean;
|
||||
}>
|
||||
> {
|
||||
const conditions = [
|
||||
@@ -822,6 +1016,7 @@ export async function listFiles(
|
||||
uploadedByName: meshFile.uploadedByName,
|
||||
uploadedAt: meshFile.uploadedAt,
|
||||
persistent: meshFile.persistent,
|
||||
encrypted: meshFile.encrypted,
|
||||
})
|
||||
.from(meshFile)
|
||||
.where(and(...conditions))
|
||||
@@ -835,6 +1030,7 @@ export async function listFiles(
|
||||
uploadedBy: r.uploadedByName ?? "unknown",
|
||||
uploadedAt: r.uploadedAt,
|
||||
persistent: r.persistent,
|
||||
encrypted: r.encrypted,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -892,11 +1088,62 @@ export async function deleteFile(
|
||||
);
|
||||
}
|
||||
|
||||
/** Insert encrypted key blobs for a newly uploaded E2E file. */
|
||||
export async function insertFileKeys(
|
||||
fileId: string,
|
||||
keys: Array<{ peerPubkey: string; sealedKey: string; grantedByPubkey?: string }>,
|
||||
): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
await db.insert(meshFileKey).values(
|
||||
keys.map((k) => ({
|
||||
fileId,
|
||||
peerPubkey: k.peerPubkey,
|
||||
sealedKey: k.sealedKey,
|
||||
grantedByPubkey: k.grantedByPubkey ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the sealed key for a specific peer, or null if not authorized. */
|
||||
export async function getFileKey(
|
||||
fileId: string,
|
||||
peerPubkey: string,
|
||||
): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ sealedKey: meshFileKey.sealedKey })
|
||||
.from(meshFileKey)
|
||||
.where(
|
||||
and(eq(meshFileKey.fileId, fileId), eq(meshFileKey.peerPubkey, peerPubkey)),
|
||||
);
|
||||
return row?.sealedKey ?? null;
|
||||
}
|
||||
|
||||
/** Grant a peer access to an encrypted file (upsert their key blob). */
|
||||
export async function grantFileKey(
|
||||
fileId: string,
|
||||
peerPubkey: string,
|
||||
sealedKey: string,
|
||||
grantedByPubkey: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(meshFileKey)
|
||||
.values({ fileId, peerPubkey, sealedKey, grantedByPubkey })
|
||||
.onConflictDoUpdate({
|
||||
target: [meshFileKey.fileId, meshFileKey.peerPubkey],
|
||||
set: { sealedKey, grantedByPubkey, grantedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Context sharing ---
|
||||
|
||||
/**
|
||||
* Upsert a context snapshot for a peer. Each (meshId, presenceId) pair
|
||||
* has at most one context row — repeated calls update it in place.
|
||||
* Upsert a context snapshot for a peer. When `memberId` is provided the
|
||||
* row is keyed on (meshId, memberId) — a stable identifier that survives
|
||||
* reconnects. This prevents stale rows from accumulating every time a
|
||||
* session reconnects with a fresh ephemeral presenceId.
|
||||
*
|
||||
* Falls back to (meshId, presenceId) lookup when memberId is absent
|
||||
* (e.g. legacy callers or anonymous connections).
|
||||
*/
|
||||
export async function shareContext(
|
||||
meshId: string,
|
||||
@@ -906,24 +1153,27 @@ export async function shareContext(
|
||||
filesRead?: string[],
|
||||
keyFindings?: string[],
|
||||
tags?: string[],
|
||||
memberId?: string,
|
||||
): Promise<string> {
|
||||
const now = new Date();
|
||||
// Try to find existing context for this presence in this mesh.
|
||||
|
||||
// Build the WHERE clause: prefer stable memberId, fall back to presenceId.
|
||||
const lookupWhere = memberId
|
||||
? and(eq(meshContext.meshId, meshId), eq(meshContext.memberId, memberId))
|
||||
: and(eq(meshContext.meshId, meshId), eq(meshContext.presenceId, presenceId));
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: meshContext.id })
|
||||
.from(meshContext)
|
||||
.where(
|
||||
and(
|
||||
eq(meshContext.meshId, meshId),
|
||||
eq(meshContext.presenceId, presenceId),
|
||||
),
|
||||
)
|
||||
.where(lookupWhere)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(meshContext)
|
||||
.set({
|
||||
// Keep presenceId current so it reflects the latest connection.
|
||||
presenceId,
|
||||
peerName: peerName ?? null,
|
||||
summary,
|
||||
filesRead: filesRead ?? [],
|
||||
@@ -939,6 +1189,7 @@ export async function shareContext(
|
||||
.insert(meshContext)
|
||||
.values({
|
||||
meshId,
|
||||
memberId: memberId ?? null,
|
||||
presenceId,
|
||||
peerName: peerName ?? null,
|
||||
summary,
|
||||
@@ -1188,16 +1439,22 @@ export async function createStream(
|
||||
name: string,
|
||||
createdByName: string,
|
||||
): Promise<string> {
|
||||
const existing = await db
|
||||
// Atomic upsert: INSERT ... ON CONFLICT DO NOTHING to avoid TOCTOU race
|
||||
// when two callers concurrently attempt to create the same stream.
|
||||
const [inserted] = await db
|
||||
.insert(meshStream)
|
||||
.values({ meshId, name, createdByName })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: meshStream.id });
|
||||
|
||||
if (inserted) return inserted.id;
|
||||
|
||||
// Row already existed — fetch the id.
|
||||
const [existing] = await db
|
||||
.select({ id: meshStream.id })
|
||||
.from(meshStream)
|
||||
.where(and(eq(meshStream.meshId, meshId), eq(meshStream.name, name)));
|
||||
if (existing.length > 0) return existing[0]!.id;
|
||||
const [row] = await db
|
||||
.insert(meshStream)
|
||||
.values({ meshId, name, createdByName })
|
||||
.returning({ id: meshStream.id });
|
||||
return row!.id;
|
||||
return existing!.id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1302,11 +1559,28 @@ export async function drainForMember(
|
||||
);
|
||||
|
||||
// Build group target matching: @all (broadcast alias) + @<groupname>
|
||||
// for each group the peer belongs to.
|
||||
// for each group the peer belongs to, expanded to all ancestor paths.
|
||||
//
|
||||
// Hierarchical routing (downward propagation):
|
||||
// A peer in "flexicar/core" also matches messages sent to "@flexicar".
|
||||
// A peer in "flexicar/core/backend" matches "@flexicar/core" and "@flexicar".
|
||||
// This lets leads send to a parent group and reach all sub-teams.
|
||||
//
|
||||
// Resolution happens at drain time (pull model) — no duplicates stored,
|
||||
// no schema changes, fully backward-compatible.
|
||||
const groupTargets = ["@all"];
|
||||
if (memberGroups) {
|
||||
const seen = new Set<string>();
|
||||
for (const g of memberGroups) {
|
||||
groupTargets.push(`@${g}`);
|
||||
const parts = g.split("/");
|
||||
// Add the group itself + every ancestor prefix.
|
||||
for (let depth = parts.length; depth > 0; depth--) {
|
||||
const ancestor = parts.slice(0, depth).join("/");
|
||||
if (!seen.has(ancestor)) {
|
||||
seen.add(ancestor);
|
||||
groupTargets.push(`@${ancestor}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const groupTargetList = sql.raw(
|
||||
@@ -1337,7 +1611,7 @@ export async function drainForMember(
|
||||
AND delivered_at IS NULL
|
||||
AND priority::text IN (${priorityList})
|
||||
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
|
||||
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
|
||||
${excludeSenderSessionPubkey ? sql`AND NOT (target_spec IN ('*') AND sender_session_pubkey = ${excludeSenderSessionPubkey})` : sql``}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
@@ -1532,13 +1806,18 @@ export async function joinMesh(args: {
|
||||
if (!claimed) return { ok: false, error: "invite_exhausted" };
|
||||
|
||||
// 6. Insert the member with the role from the payload.
|
||||
// Apply invite preset overrides (displayName, roleTag, groups, messageMode).
|
||||
const preset = (inv.preset as any) ?? {};
|
||||
const [row] = await db
|
||||
.insert(memberTable)
|
||||
.values({
|
||||
meshId: invitePayload.mesh_id,
|
||||
peerPubkey,
|
||||
displayName,
|
||||
displayName: preset.displayName ?? displayName,
|
||||
role: invitePayload.role,
|
||||
roleTag: preset.roleTag ?? null,
|
||||
defaultGroups: preset.groups ?? [],
|
||||
messageMode: preset.messageMode ?? "push",
|
||||
})
|
||||
.returning({ id: memberTable.id });
|
||||
if (!row) return { ok: false, error: "member_insert_failed" };
|
||||
@@ -1552,12 +1831,24 @@ export async function joinMesh(args: {
|
||||
export async function findMemberByPubkey(
|
||||
meshId: string,
|
||||
pubkey: string,
|
||||
): Promise<{ id: string; displayName: string; role: string } | null> {
|
||||
): Promise<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
roleTag: string | null;
|
||||
defaultGroups: Array<{ name: string; role?: string }>;
|
||||
messageMode: string | null;
|
||||
dashboardUserId: string | null;
|
||||
} | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: memberTable.id,
|
||||
displayName: memberTable.displayName,
|
||||
role: memberTable.role,
|
||||
roleTag: memberTable.roleTag,
|
||||
defaultGroups: memberTable.defaultGroups,
|
||||
messageMode: memberTable.messageMode,
|
||||
dashboardUserId: memberTable.dashboardUserId,
|
||||
})
|
||||
.from(memberTable)
|
||||
.where(
|
||||
@@ -1685,3 +1976,91 @@ export async function meshSchema(
|
||||
}
|
||||
return [...tables.entries()].map(([name, columns]) => ({ name, columns }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function vaultSet(meshId: string, memberId: string, key: string, ciphertext: string, nonce: string, sealedKey: string, entryType: "env" | "file", mountPath?: string, description?: string): Promise<string> {
|
||||
const existing = await db.select({ id: meshVaultEntry.id }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), eq(meshVaultEntry.key, key))).limit(1);
|
||||
if (existing.length > 0) {
|
||||
await db.update(meshVaultEntry).set({ ciphertext, nonce, sealedKey, entryType, mountPath: mountPath ?? null, description: description ?? null, updatedAt: new Date() }).where(eq(meshVaultEntry.id, existing[0]!.id));
|
||||
return existing[0]!.id;
|
||||
}
|
||||
const [row] = await db.insert(meshVaultEntry).values({ meshId, memberId, key, ciphertext, nonce, sealedKey, entryType, mountPath: mountPath ?? null, description: description ?? null }).returning({ id: meshVaultEntry.id });
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
export async function vaultList(meshId: string, memberId: string) {
|
||||
return db.select({ key: meshVaultEntry.key, entryType: meshVaultEntry.entryType, mountPath: meshVaultEntry.mountPath, description: meshVaultEntry.description, updatedAt: meshVaultEntry.updatedAt }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId)));
|
||||
}
|
||||
|
||||
export async function vaultDelete(meshId: string, memberId: string, key: string): Promise<boolean> {
|
||||
const deleted = await db.delete(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), eq(meshVaultEntry.key, key))).returning({ id: meshVaultEntry.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
export async function vaultGetEntries(meshId: string, memberId: string, keys: string[]) {
|
||||
if (keys.length === 0) return [];
|
||||
return db.select({ key: meshVaultEntry.key, ciphertext: meshVaultEntry.ciphertext, nonce: meshVaultEntry.nonce, sealedKey: meshVaultEntry.sealedKey, entryType: meshVaultEntry.entryType, mountPath: meshVaultEntry.mountPath }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), inArray(meshVaultEntry.key, keys)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service catalog operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function upsertService(meshId: string, name: string, data: { type: "mcp" | "skill"; sourceType: string; description: string; sourceFileId?: string; sourceGitUrl?: string; sourceGitBranch?: string; sourceGitSha?: string; instructions?: string; toolsSchema?: unknown; manifest?: unknown; runtime?: string; status?: string; config?: unknown; scope?: unknown; deployedBy?: string; deployedByName?: string }): Promise<string> {
|
||||
// Whitelist allowed fields — prevent mass-assignment of id, meshId, createdAt, etc.
|
||||
const fields: Record<string, unknown> = {
|
||||
type: data.type,
|
||||
sourceType: data.sourceType,
|
||||
description: data.description,
|
||||
...(data.sourceFileId !== undefined && { sourceFileId: data.sourceFileId }),
|
||||
...(data.sourceGitUrl !== undefined && { sourceGitUrl: data.sourceGitUrl }),
|
||||
...(data.sourceGitBranch !== undefined && { sourceGitBranch: data.sourceGitBranch }),
|
||||
...(data.sourceGitSha !== undefined && { sourceGitSha: data.sourceGitSha }),
|
||||
...(data.instructions !== undefined && { instructions: data.instructions }),
|
||||
...(data.toolsSchema !== undefined && { toolsSchema: data.toolsSchema }),
|
||||
...(data.manifest !== undefined && { manifest: data.manifest }),
|
||||
...(data.runtime !== undefined && { runtime: data.runtime }),
|
||||
...(data.status !== undefined && { status: data.status }),
|
||||
...(data.config !== undefined && { config: data.config }),
|
||||
...(data.scope !== undefined && { scope: data.scope }),
|
||||
...(data.deployedBy !== undefined && { deployedBy: data.deployedBy }),
|
||||
...(data.deployedByName !== undefined && { deployedByName: data.deployedByName }),
|
||||
};
|
||||
|
||||
const existing = await db.select({ id: meshService.id }).from(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).limit(1);
|
||||
if (existing.length > 0) {
|
||||
await db.update(meshService).set({ ...fields, updatedAt: new Date() } as any).where(eq(meshService.id, existing[0]!.id));
|
||||
return existing[0]!.id;
|
||||
}
|
||||
const [row] = await db.insert(meshService).values({ meshId, name, ...fields } as any).returning({ id: meshService.id });
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
export async function updateServiceStatus(meshId: string, name: string, status: string, extra?: { toolsSchema?: unknown; restartCount?: number; lastHealth?: Date }) {
|
||||
await db.update(meshService).set({ status, ...(extra ?? {}), updatedAt: new Date() } as any).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name)));
|
||||
}
|
||||
|
||||
export async function updateServiceScope(meshId: string, name: string, scope: unknown) {
|
||||
await db.update(meshService).set({ scope, updatedAt: new Date() } as any).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name)));
|
||||
}
|
||||
|
||||
export async function getService(meshId: string, name: string) {
|
||||
const rows = await db.select().from(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listDbMeshServices(meshId: string) {
|
||||
return db.select().from(meshService).where(eq(meshService.meshId, meshId));
|
||||
}
|
||||
|
||||
export async function deleteService(meshId: string, name: string): Promise<boolean> {
|
||||
const deleted = await db.delete(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).returning({ id: meshService.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
export async function getRunningServices(meshId: string) {
|
||||
return db.select().from(meshService).where(and(eq(meshService.meshId, meshId), inArray(meshService.status, ["running", "failed", "crashed", "restarting"])));
|
||||
}
|
||||
|
||||
133
apps/broker/src/cli-sync.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* POST /cli-sync handler.
|
||||
*
|
||||
* Accepts a sync JWT from the dashboard, creates or finds member rows
|
||||
* for each mesh in the token, and returns mesh details + member IDs.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { verifySyncToken, type SyncTokenPayload } from "./jwt";
|
||||
|
||||
// Import schema tables
|
||||
import {
|
||||
mesh as meshTable,
|
||||
meshMember as memberTable,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
export interface CliSyncRequest {
|
||||
sync_token: string;
|
||||
peer_pubkey: string; // ed25519 hex (64 chars)
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface CliSyncResponse {
|
||||
ok: true;
|
||||
account_id: string;
|
||||
meshes: Array<{
|
||||
mesh_id: string;
|
||||
slug: string;
|
||||
broker_url: string;
|
||||
member_id: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CliSyncError {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export async function handleCliSync(
|
||||
body: CliSyncRequest,
|
||||
): Promise<CliSyncResponse | CliSyncError> {
|
||||
// 1. Validate inputs
|
||||
if (!body.sync_token || !body.peer_pubkey || !body.display_name) {
|
||||
return { ok: false, error: "sync_token, peer_pubkey, display_name required" };
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(body.peer_pubkey)) {
|
||||
return { ok: false, error: "peer_pubkey must be 64 hex chars (32 bytes)" };
|
||||
}
|
||||
|
||||
// 2. Verify JWT
|
||||
const tokenResult = await verifySyncToken(body.sync_token);
|
||||
if (!tokenResult.ok) {
|
||||
return { ok: false, error: `sync token invalid: ${tokenResult.error}` };
|
||||
}
|
||||
const payload = tokenResult.payload;
|
||||
|
||||
// 3. For each mesh in the token, create or find a member row
|
||||
const resultMeshes: CliSyncResponse["meshes"] = [];
|
||||
|
||||
for (const tokenMesh of payload.meshes) {
|
||||
// Verify mesh exists and is not archived
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id, slug: meshTable.slug })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, tokenMesh.id), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) {
|
||||
// Skip meshes that don't exist (could have been deleted)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this pubkey is already a member of this mesh
|
||||
const [existing] = await db
|
||||
.select({ id: memberTable.id, role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.meshId, tokenMesh.id),
|
||||
eq(memberTable.peerPubkey, body.peer_pubkey),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
let memberId: string;
|
||||
let role: "admin" | "member";
|
||||
|
||||
if (existing) {
|
||||
// Already a member — update dashboard link + display name
|
||||
memberId = existing.id;
|
||||
role = existing.role;
|
||||
await db
|
||||
.update(memberTable)
|
||||
.set({
|
||||
dashboardUserId: payload.sub,
|
||||
displayName: body.display_name,
|
||||
})
|
||||
.where(eq(memberTable.id, existing.id));
|
||||
} else {
|
||||
// Create new member row
|
||||
memberId = generateId();
|
||||
role = tokenMesh.role;
|
||||
await db.insert(memberTable).values({
|
||||
id: memberId,
|
||||
meshId: tokenMesh.id,
|
||||
peerPubkey: body.peer_pubkey,
|
||||
displayName: body.display_name,
|
||||
role: tokenMesh.role,
|
||||
dashboardUserId: payload.sub,
|
||||
});
|
||||
}
|
||||
|
||||
resultMeshes.push({
|
||||
mesh_id: tokenMesh.id,
|
||||
slug: m.slug,
|
||||
broker_url: process.env.BROKER_PUBLIC_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
member_id: memberId,
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
if (resultMeshes.length === 0) {
|
||||
return { ok: false, error: "no valid meshes found in sync token" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
account_id: payload.sub,
|
||||
meshes: resultMeshes,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
* current member of the claimed mesh.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { db } from "./db";
|
||||
import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||
|
||||
let ready = false;
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
@@ -69,6 +72,70 @@ export async function verifyEd25519(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical v2 invite bytes — signed by the mesh owner's ed25519 secret key.
|
||||
* NOTE: deliberately does NOT include the root_key or broker_url; the v2
|
||||
* protocol moves the root_key out of the URL entirely. Format is locked:
|
||||
* `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey` (no trailing newline).
|
||||
*/
|
||||
export function canonicalInviteV2(p: {
|
||||
mesh_id: string;
|
||||
invite_id: string;
|
||||
expires_at: number; // unix seconds
|
||||
role: "admin" | "member";
|
||||
owner_pubkey: string; // hex
|
||||
}): string {
|
||||
return `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an ed25519 signature over the v2 canonical invite bytes against
|
||||
* the mesh owner's public key. Returns true on valid signature.
|
||||
*/
|
||||
export async function verifyInviteV2(params: {
|
||||
canonical: string;
|
||||
signatureHex: string;
|
||||
ownerPubkeyHex: string;
|
||||
}): Promise<boolean> {
|
||||
return verifyEd25519(
|
||||
params.canonical,
|
||||
params.signatureHex,
|
||||
params.ownerPubkeyHex,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal the mesh root_key to a recipient-provided x25519 public key using
|
||||
* libsodium's sealed box (crypto_box_seal). Only the holder of the matching
|
||||
* x25519 secret key can unseal.
|
||||
*
|
||||
* rootKeyBase64url is the mesh.root_key column value (base64url of 32 bytes).
|
||||
* recipientX25519PubkeyBase64url is the 32-byte x25519 pubkey the recipient
|
||||
* provided in its claim request. We do NOT convert an ed25519 pubkey here —
|
||||
* the recipient generates a dedicated x25519 keypair and sends us the pubkey.
|
||||
*
|
||||
* Returns base64url of the sealed ciphertext.
|
||||
*/
|
||||
export async function sealRootKeyToRecipient(params: {
|
||||
rootKeyBase64url: string;
|
||||
recipientX25519PubkeyBase64url: string;
|
||||
}): Promise<string> {
|
||||
const s = await ensureSodium();
|
||||
const rootKeyBytes = s.from_base64(
|
||||
params.rootKeyBase64url,
|
||||
s.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
const recipientPk = s.from_base64(
|
||||
params.recipientX25519PubkeyBase64url,
|
||||
s.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
if (recipientPk.length !== 32) {
|
||||
throw new Error("recipient_x25519_pubkey must decode to 32 bytes");
|
||||
}
|
||||
const sealed = s.crypto_box_seal(rootKeyBytes, recipientPk);
|
||||
return s.to_base64(sealed, s.base64_variants.URLSAFE_NO_PADDING);
|
||||
}
|
||||
|
||||
export const HELLO_SKEW_MS = 60_000;
|
||||
|
||||
/**
|
||||
@@ -118,3 +185,185 @@ export async function verifyHelloSignature(args: {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// v2 invite claim core — exported for the HTTP handler in index.ts AND for
|
||||
// tests that need to exercise the logic without spinning up the broker server.
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// capabilityV2 column is stored as JSON:
|
||||
// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
|
||||
// "signature": "<hex ed25519 detached signature>" }
|
||||
// The broker recomputes the canonical bytes from the invite row and verifies
|
||||
// the signature against mesh.ownerPubkey. v1 rows (version === 1 OR
|
||||
// capabilityV2 === null) skip verification — the legacy path still works
|
||||
// during the deprecation window.
|
||||
|
||||
export type InviteClaimV2Result =
|
||||
| {
|
||||
ok: true;
|
||||
status: 200;
|
||||
body: {
|
||||
sealed_root_key: string;
|
||||
mesh_id: string;
|
||||
member_id: string;
|
||||
owner_pubkey: string;
|
||||
canonical_v2: string;
|
||||
};
|
||||
}
|
||||
| { ok: false; status: 400 | 404 | 410; body: { error: string } };
|
||||
|
||||
export async function claimInviteV2Core(params: {
|
||||
code: string;
|
||||
recipientX25519PubkeyBase64url: string;
|
||||
displayName?: string;
|
||||
now?: number;
|
||||
}): Promise<InviteClaimV2Result> {
|
||||
const now = params.now ?? Date.now();
|
||||
const recipientPk = params.recipientX25519PubkeyBase64url;
|
||||
|
||||
if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
|
||||
// 1. Look up the invite by opaque code.
|
||||
const [inv] = await db
|
||||
.select()
|
||||
.from(inviteTable)
|
||||
.where(eq(inviteTable.code, params.code))
|
||||
.limit(1);
|
||||
if (!inv) return { ok: false, status: 404, body: { error: "not_found" } };
|
||||
|
||||
// 2. Lifecycle checks: revoked → expired → exhausted.
|
||||
if (inv.revokedAt) {
|
||||
return { ok: false, status: 410, body: { error: "revoked" } };
|
||||
}
|
||||
if (inv.expiresAt.getTime() < now) {
|
||||
return { ok: false, status: 410, body: { error: "expired" } };
|
||||
}
|
||||
if (inv.usedCount >= inv.maxUses) {
|
||||
return { ok: false, status: 410, body: { error: "exhausted" } };
|
||||
}
|
||||
|
||||
// 3. Load the mesh for owner_pubkey + root_key.
|
||||
const [m] = await db
|
||||
.select({
|
||||
id: mesh.id,
|
||||
ownerPubkey: mesh.ownerPubkey,
|
||||
rootKey: mesh.rootKey,
|
||||
})
|
||||
.from(mesh)
|
||||
.where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt)))
|
||||
.limit(1);
|
||||
if (!m) return { ok: false, status: 404, body: { error: "not_found" } };
|
||||
if (!m.ownerPubkey || !m.rootKey) {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
|
||||
// 4. Compute canonical_v2 from the row (used in the response either way).
|
||||
const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000);
|
||||
const canonical = canonicalInviteV2({
|
||||
mesh_id: inv.meshId,
|
||||
invite_id: inv.id,
|
||||
expires_at: expiresAtUnix,
|
||||
role: inv.role as "admin" | "member",
|
||||
owner_pubkey: m.ownerPubkey,
|
||||
});
|
||||
|
||||
if (inv.version === 2 && inv.capabilityV2) {
|
||||
let storedCanonical: string | undefined;
|
||||
let signatureHex: string | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(inv.capabilityV2) as {
|
||||
canonical?: string;
|
||||
signature?: string;
|
||||
};
|
||||
storedCanonical = parsed.canonical;
|
||||
signatureHex = parsed.signature;
|
||||
} catch {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
if (!storedCanonical || !signatureHex) {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
// Broker-recomputed canonical must match the signed bytes exactly.
|
||||
if (storedCanonical !== canonical) {
|
||||
return { ok: false, status: 400, body: { error: "bad_signature" } };
|
||||
}
|
||||
const sigOk = await verifyInviteV2({
|
||||
canonical: storedCanonical,
|
||||
signatureHex,
|
||||
ownerPubkeyHex: m.ownerPubkey,
|
||||
});
|
||||
if (!sigOk) {
|
||||
return { ok: false, status: 400, body: { error: "bad_signature" } };
|
||||
}
|
||||
}
|
||||
// v1 rows: skip signature verification (legacy path during migration).
|
||||
|
||||
// 5. Atomic consume: increment used_count iff still under max_uses.
|
||||
const [claimed] = await db
|
||||
.update(inviteTable)
|
||||
.set({
|
||||
usedCount: sql`${inviteTable.usedCount} + 1`,
|
||||
claimedByPubkey: recipientPk,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(inviteTable.id, inv.id),
|
||||
lt(inviteTable.usedCount, inv.maxUses),
|
||||
),
|
||||
)
|
||||
.returning({ id: inviteTable.id });
|
||||
if (!claimed) {
|
||||
return { ok: false, status: 410, body: { error: "exhausted" } };
|
||||
}
|
||||
|
||||
// 6. Create a member row for the claimant.
|
||||
const preset = (inv.preset as {
|
||||
displayName?: string;
|
||||
roleTag?: string;
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
messageMode?: string;
|
||||
} | null) ?? {};
|
||||
const displayName =
|
||||
preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`;
|
||||
const [row] = await db
|
||||
.insert(meshMember)
|
||||
.values({
|
||||
meshId: inv.meshId,
|
||||
peerPubkey: recipientPk,
|
||||
displayName,
|
||||
role: inv.role,
|
||||
roleTag: preset.roleTag ?? null,
|
||||
defaultGroups: preset.groups ?? [],
|
||||
messageMode: preset.messageMode ?? "push",
|
||||
})
|
||||
.returning({ id: meshMember.id });
|
||||
if (!row) {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
|
||||
// 7. Seal the mesh root_key to the recipient's x25519 pubkey.
|
||||
let sealed: string;
|
||||
try {
|
||||
sealed = await sealRootKeyToRecipient({
|
||||
rootKeyBase64url: m.rootKey,
|
||||
recipientX25519PubkeyBase64url: recipientPk,
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
sealed_root_key: sealed,
|
||||
mesh_id: inv.meshId,
|
||||
member_id: row.id,
|
||||
owner_pubkey: m.ownerPubkey,
|
||||
canonical_v2: canonical,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
320
apps/broker/src/emails/mesh-invitation.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
interface MeshInvitationProps {
|
||||
meshName: string;
|
||||
inviteUrl: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
appBaseUrl: string;
|
||||
}
|
||||
|
||||
// Brand tokens — mirror of apps/web/src/assets/styles/globals.css (--cm-*).
|
||||
// Inlined here because email clients don't resolve CSS vars.
|
||||
const brand = {
|
||||
bg: "#141413",
|
||||
bgElevated: "#1f1e1d",
|
||||
bgCode: "#0f0e0d",
|
||||
fg: "#faf9f5",
|
||||
fgSecondary: "#c2c0b6",
|
||||
fgTertiary: "#87867f",
|
||||
clay: "#d97757",
|
||||
clayBorder: "rgba(217, 119, 87, 0.35)",
|
||||
border: "rgba(217, 119, 87, 0.2)",
|
||||
serif: 'Georgia, "Times New Roman", serif',
|
||||
mono: '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace',
|
||||
sans:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
||||
} as const;
|
||||
|
||||
export const MeshInvitation = ({
|
||||
meshName,
|
||||
inviteUrl,
|
||||
token,
|
||||
expiresAt,
|
||||
appBaseUrl,
|
||||
}: MeshInvitationProps) => {
|
||||
const expiresLabel = new Date(expiresAt).toUTCString();
|
||||
const launchCmd = `claudemesh launch --join ${inviteUrl}`;
|
||||
const oneLiner = `npm i -g claudemesh-cli && ${launchCmd}`;
|
||||
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="supported-color-schemes" content="dark" />
|
||||
</Head>
|
||||
<Preview>You've been invited to the {meshName} mesh on claudemesh</Preview>
|
||||
<Body
|
||||
style={{
|
||||
backgroundColor: brand.bg,
|
||||
color: brand.fg,
|
||||
fontFamily: brand.sans,
|
||||
margin: 0,
|
||||
padding: "40px 0",
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
style={{
|
||||
maxWidth: "560px",
|
||||
margin: "0 auto",
|
||||
padding: "0 24px",
|
||||
}}
|
||||
>
|
||||
{/* Header — mesh glyph + wordmark */}
|
||||
<Section style={{ marginBottom: "40px" }}>
|
||||
<table role="presentation" cellPadding={0} cellSpacing={0} border={0}>
|
||||
<tr>
|
||||
<td style={{ verticalAlign: "middle", paddingRight: "10px" }}>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="4" r="2" fill={brand.clay} />
|
||||
<circle cx="4" cy="12" r="2" fill={brand.clay} />
|
||||
<circle cx="20" cy="12" r="2" fill={brand.clay} />
|
||||
<circle cx="12" cy="20" r="2" fill={brand.clay} />
|
||||
<path
|
||||
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
|
||||
stroke={brand.clay}
|
||||
strokeWidth="1.2"
|
||||
opacity="0.45"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</td>
|
||||
<td style={{ verticalAlign: "middle" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "17px",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.01em",
|
||||
color: brand.fg,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
claudemesh
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
{/* Eyebrow */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.22em",
|
||||
color: brand.clay,
|
||||
margin: "0 0 16px 0",
|
||||
}}
|
||||
>
|
||||
— you're invited
|
||||
</Text>
|
||||
|
||||
{/* Heading */}
|
||||
<Heading
|
||||
as="h1"
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "32px",
|
||||
fontWeight: 500,
|
||||
lineHeight: "1.15",
|
||||
letterSpacing: "-0.01em",
|
||||
color: brand.fg,
|
||||
margin: "0 0 20px 0",
|
||||
}}
|
||||
>
|
||||
Join{" "}
|
||||
<span style={{ fontFamily: brand.mono, color: brand.clay }}>
|
||||
{meshName}
|
||||
</span>{" "}
|
||||
on claudemesh
|
||||
</Heading>
|
||||
|
||||
{/* Body prose */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "16px",
|
||||
lineHeight: "1.65",
|
||||
color: brand.fgSecondary,
|
||||
margin: "0 0 32px 0",
|
||||
}}
|
||||
>
|
||||
claudemesh is a peer mesh for Claude Code sessions — end-to-end
|
||||
encrypted, keys stay on your machine. Open the link below to see
|
||||
the mesh, the inviter, and the command to join.
|
||||
</Text>
|
||||
|
||||
{/* Primary CTA */}
|
||||
<Section style={{ marginBottom: "36px" }}>
|
||||
<Button
|
||||
href={inviteUrl}
|
||||
style={{
|
||||
backgroundColor: brand.clay,
|
||||
color: brand.fg,
|
||||
fontFamily: brand.sans,
|
||||
fontSize: "15px",
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
padding: "14px 28px",
|
||||
borderRadius: "4px",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
Open invite →
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
{/* Terminal shortcut — for the already-set-up crowd */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.22em",
|
||||
color: brand.fgTertiary,
|
||||
margin: "0 0 12px 0",
|
||||
}}
|
||||
>
|
||||
— already have the CLI?
|
||||
</Text>
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: brand.bgElevated,
|
||||
border: `1px solid ${brand.clayBorder}`,
|
||||
borderRadius: "6px",
|
||||
padding: "16px 18px",
|
||||
marginBottom: "32px",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "12px",
|
||||
color: brand.fg,
|
||||
margin: 0,
|
||||
wordBreak: "break-all",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
{launchCmd}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* First-time one-liner */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.22em",
|
||||
color: brand.fgTertiary,
|
||||
margin: "0 0 12px 0",
|
||||
}}
|
||||
>
|
||||
— first time? one command
|
||||
</Text>
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: brand.bgElevated,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: "6px",
|
||||
padding: "16px 18px",
|
||||
marginBottom: "32px",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "12px",
|
||||
color: brand.fg,
|
||||
margin: 0,
|
||||
lineHeight: "1.6",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{oneLiner}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "12px",
|
||||
color: brand.fgTertiary,
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
Requires Node.js 20+. Display name defaults to $USER.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${brand.border}`,
|
||||
margin: "28px 0 20px 0",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Footer meta */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
color: brand.fgTertiary,
|
||||
margin: "0 0 8px 0",
|
||||
}}
|
||||
>
|
||||
Expires{" "}
|
||||
<span style={{ color: brand.fgSecondary }}>{expiresLabel}</span>.
|
||||
If you weren't expecting this, you can ignore it.
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
color: brand.fgTertiary,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={appBaseUrl}
|
||||
style={{ color: brand.fgTertiary, textDecoration: "underline" }}
|
||||
>
|
||||
claudemesh.com
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
MeshInvitation.PreviewProps = {
|
||||
meshName: "prueba1",
|
||||
inviteUrl: "https://claudemesh.com/i/RUVMYXZQ",
|
||||
token: "eyJ2IjoxLCJtZXNoX2lkIjoiQUtMYUZxR3FKOGZCajN0U3dvVk1PSFYxQmF3UGlYTE8iLCJtZXNoX3NsdWciOiJwcnVlYmExIn0",
|
||||
expiresAt: "2026-04-22T00:51:26.181Z",
|
||||
appBaseUrl: "https://claudemesh.com",
|
||||
} satisfies MeshInvitationProps;
|
||||
|
||||
export default MeshInvitation;
|
||||
@@ -23,11 +23,18 @@ const envSchema = z.object({
|
||||
MINIO_ENDPOINT: z.string().default("minio:9000"),
|
||||
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
||||
MINIO_SECRET_KEY: z.string().default("changeme"),
|
||||
MINIO_USE_SSL: z.coerce.boolean().default(false),
|
||||
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
|
||||
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||
NEO4J_USER: z.string().default("neo4j"),
|
||||
NEO4J_PASSWORD: z.string().default("changeme"),
|
||||
RUNNER_URL: z.string().default("http://runner:7901"),
|
||||
CLAUDEMESH_SERVICES_DIR: z.string().default("/var/claudemesh/services"),
|
||||
BROKER_ENCRYPTION_KEY: z.string().default(""), // 64 hex chars (32 bytes). Auto-generated if empty.
|
||||
CLI_SYNC_SECRET: z.string().default(""), // HS256 shared secret for dashboard→broker sync JWTs. Required for /cli-sync.
|
||||
MAX_SERVICES_PER_MESH: z.coerce.number().int().positive().default(20),
|
||||
MAX_SERVICE_ZIP_BYTES: z.coerce.number().int().positive().default(50 * 1024 * 1024),
|
||||
ANTHROPIC_API_KEY: z.string().default(""), // Claude API key for Telegram AI bot
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
|
||||
146
apps/broker/src/jwt.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* JWT verification for CLI sync tokens.
|
||||
*
|
||||
* Sync tokens are HS256 JWTs issued by the dashboard after OAuth,
|
||||
* shared secret between dashboard and broker via env var.
|
||||
*
|
||||
* JTI dedup: tracks used token IDs in a TTL-evicted Set to prevent replay.
|
||||
*/
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface SyncTokenPayload {
|
||||
sub: string; // dashboard user ID
|
||||
email: string;
|
||||
meshes: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
action: "sync" | "create";
|
||||
newMesh?: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
jti: string; // unique token ID for replay prevention
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// --- JTI dedup ---
|
||||
|
||||
const usedJtis = new Map<string, number>(); // jti → expiry timestamp (ms)
|
||||
|
||||
// Sweep expired JTIs every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [jti, exp] of usedJtis) {
|
||||
if (exp < now) usedJtis.delete(jti);
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
|
||||
// --- Verification ---
|
||||
|
||||
/**
|
||||
* Verify and decode a sync token JWT.
|
||||
* Returns the decoded payload on success, or an error string on failure.
|
||||
*/
|
||||
export async function verifySyncToken(
|
||||
token: string,
|
||||
): Promise<{ ok: true; payload: SyncTokenPayload } | { ok: false; error: string }> {
|
||||
// Get shared secret from env
|
||||
const secret = env.CLI_SYNC_SECRET;
|
||||
if (!secret) {
|
||||
return { ok: false, error: "CLI_SYNC_SECRET not configured on broker" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode JWT manually (HS256)
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return { ok: false, error: "malformed JWT" };
|
||||
}
|
||||
|
||||
const headerB64 = parts[0]!;
|
||||
const payloadB64 = parts[1]!;
|
||||
const signatureB64 = parts[2]!;
|
||||
|
||||
// Verify signature (HS256)
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
|
||||
const signatureInput = encoder.encode(`${headerB64}.${payloadB64}`);
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
|
||||
const valid = await crypto.subtle.verify("HMAC", key, signature, signatureInput);
|
||||
if (!valid) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
|
||||
// Decode header — must be HS256
|
||||
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)));
|
||||
if (header.alg !== "HS256") {
|
||||
return { ok: false, error: `unsupported algorithm: ${header.alg}` };
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(payloadB64)),
|
||||
) as SyncTokenPayload;
|
||||
|
||||
// Check expiry
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp && payload.exp < now) {
|
||||
return { ok: false, error: "token expired" };
|
||||
}
|
||||
|
||||
// Check iat not in the future (30s tolerance)
|
||||
if (payload.iat && payload.iat > now + 30) {
|
||||
return { ok: false, error: "token issued in the future" };
|
||||
}
|
||||
|
||||
// JTI dedup
|
||||
if (!payload.jti) {
|
||||
return { ok: false, error: "missing jti" };
|
||||
}
|
||||
if (usedJtis.has(payload.jti)) {
|
||||
return { ok: false, error: "token already used" };
|
||||
}
|
||||
// Mark as used with expiry time
|
||||
usedJtis.set(payload.jti, (payload.exp ?? now + 900) * 1000);
|
||||
|
||||
// Basic validation
|
||||
if (!payload.sub || !payload.email) {
|
||||
return { ok: false, error: "missing sub or email" };
|
||||
}
|
||||
if (!Array.isArray(payload.meshes)) {
|
||||
return { ok: false, error: "missing meshes array" };
|
||||
}
|
||||
|
||||
return { ok: true, payload };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function base64UrlDecode(input: string): Uint8Array {
|
||||
// Add padding
|
||||
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (base64.length % 4) base64 += "=";
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
284
apps/broker/src/member-api.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Member profile REST API handlers.
|
||||
*
|
||||
* PATCH /mesh/:meshId/member/:memberId — update member profile
|
||||
* GET /mesh/:meshId/members — list all members with online status
|
||||
* PATCH /mesh/:meshId/settings — update mesh settings (selfEditable)
|
||||
*
|
||||
* These are standalone handler functions. Route wiring happens in index.ts.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import {
|
||||
mesh as meshTable,
|
||||
meshMember as memberTable,
|
||||
presence as presenceTable,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface MemberProfileUpdate {
|
||||
displayName?: string;
|
||||
roleTag?: string;
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
}
|
||||
|
||||
export interface MemberPermissionUpdate {
|
||||
permission?: "admin" | "member"; // only admins can change this
|
||||
}
|
||||
|
||||
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
|
||||
|
||||
interface SelfEditablePolicy {
|
||||
displayName: boolean;
|
||||
roleTag: boolean;
|
||||
groups: boolean;
|
||||
messageMode: boolean;
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
/**
|
||||
* Update a member's profile fields.
|
||||
*
|
||||
* Authorization:
|
||||
* - If caller is the target member: check mesh.selfEditable for each field
|
||||
* - If caller is a mesh admin: allow all fields
|
||||
* - permission field: admin-only always
|
||||
*
|
||||
* Returns: { ok: true, member: {...} } or { ok: false, error: string }
|
||||
*/
|
||||
export async function updateMemberProfile(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
callerMemberId: string, // from auth header or WS connection
|
||||
updates: MemberUpdateRequest,
|
||||
): Promise<
|
||||
| { ok: true; member: Record<string, unknown>; changes: MemberProfileUpdate }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
// 1. Load mesh for selfEditable policy
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id, selfEditable: meshTable.selfEditable })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) return { ok: false, error: "mesh not found" };
|
||||
|
||||
// 2. Load caller's member row to check permission
|
||||
const [caller] = await db
|
||||
.select({ id: memberTable.id, role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.id, callerMemberId),
|
||||
eq(memberTable.meshId, meshId),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!caller) return { ok: false, error: "caller not a member of this mesh" };
|
||||
|
||||
const isAdmin = caller.role === "admin";
|
||||
const isSelf = callerMemberId === memberId;
|
||||
|
||||
if (!isAdmin && !isSelf) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "not authorized — only admins or self can edit",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Check self-edit permissions for non-admin self-edits
|
||||
const policy: SelfEditablePolicy =
|
||||
(m.selfEditable as SelfEditablePolicy) ?? {
|
||||
displayName: true,
|
||||
roleTag: true,
|
||||
groups: true,
|
||||
messageMode: true,
|
||||
};
|
||||
|
||||
const rejected: string[] = [];
|
||||
if (!isAdmin && isSelf) {
|
||||
if (updates.displayName !== undefined && !policy.displayName)
|
||||
rejected.push("displayName");
|
||||
if (updates.roleTag !== undefined && !policy.roleTag)
|
||||
rejected.push("roleTag");
|
||||
if (updates.groups !== undefined && !policy.groups)
|
||||
rejected.push("groups");
|
||||
if (updates.messageMode !== undefined && !policy.messageMode)
|
||||
rejected.push("messageMode");
|
||||
if (updates.permission !== undefined) rejected.push("permission");
|
||||
}
|
||||
|
||||
if (rejected.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `admin-managed fields: ${rejected.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Build update set
|
||||
const set: Record<string, unknown> = {};
|
||||
const changes: MemberProfileUpdate = {};
|
||||
|
||||
if (updates.displayName !== undefined) {
|
||||
set.displayName = updates.displayName;
|
||||
changes.displayName = updates.displayName;
|
||||
}
|
||||
if (updates.roleTag !== undefined) {
|
||||
set.roleTag = updates.roleTag;
|
||||
changes.roleTag = updates.roleTag;
|
||||
}
|
||||
if (updates.groups !== undefined) {
|
||||
set.defaultGroups = updates.groups;
|
||||
changes.groups = updates.groups;
|
||||
}
|
||||
if (updates.messageMode !== undefined) {
|
||||
set.messageMode = updates.messageMode;
|
||||
changes.messageMode = updates.messageMode;
|
||||
}
|
||||
if (updates.permission !== undefined && isAdmin) {
|
||||
set.role = updates.permission;
|
||||
}
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return { ok: false, error: "no fields to update" };
|
||||
}
|
||||
|
||||
// 5. Update member row
|
||||
await db.update(memberTable).set(set).where(eq(memberTable.id, memberId));
|
||||
|
||||
// 6. Read back the updated member
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(eq(memberTable.id, memberId));
|
||||
|
||||
if (!updated) return { ok: false, error: "member not found after update" };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
member: {
|
||||
id: updated.id,
|
||||
displayName: updated.displayName,
|
||||
roleTag: updated.roleTag,
|
||||
groups: updated.defaultGroups,
|
||||
messageMode: updated.messageMode,
|
||||
permission: updated.role,
|
||||
dashboardUserId: updated.dashboardUserId,
|
||||
joinedAt: updated.joinedAt,
|
||||
lastSeenAt: updated.lastSeenAt,
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all members of a mesh with online status.
|
||||
*/
|
||||
export async function listMeshMembers(
|
||||
meshId: string,
|
||||
): Promise<
|
||||
| { ok: true; members: Array<Record<string, unknown>> }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
// Verify mesh exists
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) return { ok: false, error: "mesh not found" };
|
||||
|
||||
// Get all non-revoked members
|
||||
const members = await db
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(eq(memberTable.meshId, meshId), isNull(memberTable.revokedAt)),
|
||||
);
|
||||
|
||||
// Early return for empty member list (avoids invalid SQL IN clause)
|
||||
if (members.length === 0) {
|
||||
return { ok: true, members: [] };
|
||||
}
|
||||
|
||||
// Get active presences for online status
|
||||
const activePresences = await db
|
||||
.select({
|
||||
memberId: presenceTable.memberId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(presenceTable)
|
||||
.where(
|
||||
and(
|
||||
isNull(presenceTable.disconnectedAt),
|
||||
sql`${presenceTable.memberId} IN (${sql.join(
|
||||
members.map((m) => sql`${m.id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
)
|
||||
.groupBy(presenceTable.memberId);
|
||||
|
||||
const onlineMap = new Map(
|
||||
activePresences.map((p) => [p.memberId, p.count]),
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
members: members.map((member) => ({
|
||||
id: member.id,
|
||||
displayName: member.displayName,
|
||||
roleTag: member.roleTag,
|
||||
groups: member.defaultGroups,
|
||||
messageMode: member.messageMode,
|
||||
permission: member.role,
|
||||
dashboardUserId: member.dashboardUserId,
|
||||
joinedAt: member.joinedAt?.toISOString(),
|
||||
lastSeenAt: member.lastSeenAt?.toISOString(),
|
||||
online: onlineMap.has(member.id),
|
||||
sessionCount: onlineMap.get(member.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mesh settings (currently: selfEditable policy).
|
||||
* Admin-only.
|
||||
*/
|
||||
export async function updateMeshSettings(
|
||||
meshId: string,
|
||||
callerMemberId: string,
|
||||
settings: { selfEditable?: SelfEditablePolicy },
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
// Check caller is admin
|
||||
const [caller] = await db
|
||||
.select({ role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.id, callerMemberId),
|
||||
eq(memberTable.meshId, meshId),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!caller || caller.role !== "admin") {
|
||||
return { ok: false, error: "admin access required" };
|
||||
}
|
||||
|
||||
const set: Record<string, unknown> = {};
|
||||
if (settings.selfEditable) set.selfEditable = settings.selfEditable;
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return { ok: false, error: "no settings to update" };
|
||||
}
|
||||
|
||||
await db.update(meshTable).set(set).where(eq(meshTable.id, meshId));
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -90,6 +90,10 @@ export const metrics = {
|
||||
"broker_messages_rejected_total",
|
||||
"Messages rejected (size, auth, malformed)",
|
||||
),
|
||||
messagesDroppedByGrantTotal: new Counter(
|
||||
"broker_messages_dropped_by_grant_total",
|
||||
"Messages silently dropped because recipient didn't grant sender the required capability",
|
||||
),
|
||||
queueDepth: new Gauge(
|
||||
"broker_queue_depth",
|
||||
"Undelivered messages currently in the queue",
|
||||
|
||||
59
apps/broker/src/migrate.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Runtime migrations on broker startup.
|
||||
*
|
||||
* Runs pending drizzle migrations against DATABASE_URL before the broker
|
||||
* listens. Uses pg_advisory_lock so a multi-instance deploy doesn't race.
|
||||
* If migrations fail, the process exits non-zero so the orchestrator (Coolify
|
||||
* healthcheck) sees the container as broken and doesn't route traffic.
|
||||
*/
|
||||
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
import postgres from "postgres";
|
||||
import { dirname, join } from "node:path";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
|
||||
const LOCK_ID = 74737_73831; // "cmsh" ascii — stable magic constant
|
||||
|
||||
export async function runMigrationsOnStartup(): Promise<void> {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("[migrate] DATABASE_URL not set — skipping auto-migrate");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the migrations folder — it's shipped inside @turbostarter/db's
|
||||
// deploy subset in the runtime image. Dev path also works.
|
||||
const candidates = [
|
||||
"/app/migrations",
|
||||
"/app/node_modules/@turbostarter/db/migrations",
|
||||
join(process.cwd(), "..", "..", "packages", "db", "migrations"),
|
||||
join(process.cwd(), "packages", "db", "migrations"),
|
||||
];
|
||||
const migrationsFolder = candidates.find((p) => existsSync(p));
|
||||
if (!migrationsFolder) {
|
||||
console.error("[migrate] migrations folder not found — skipping. Searched:", candidates);
|
||||
return;
|
||||
}
|
||||
const count = readdirSync(migrationsFolder).filter((f) => f.endsWith(".sql")).length;
|
||||
console.log(`[migrate] ${count} migration files at ${migrationsFolder}`);
|
||||
|
||||
const sql = postgres(url, { max: 1, onnotice: () => { /* quiet */ } });
|
||||
try {
|
||||
// Advisory lock so parallel instances serialise.
|
||||
await sql`SELECT pg_advisory_lock(${LOCK_ID})`;
|
||||
try {
|
||||
const db = drizzle(sql);
|
||||
const start = Date.now();
|
||||
await migrate(db, { migrationsFolder });
|
||||
console.log(`[migrate] ok (${Date.now() - start}ms)`);
|
||||
} finally {
|
||||
await sql`SELECT pg_advisory_unlock(${LOCK_ID})`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[migrate] FAILED:", e instanceof Error ? e.message : e);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
112
apps/broker/src/permissions.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Granular permission checks for mesh operations.
|
||||
*
|
||||
* If a meshPermission row exists for the member, use it.
|
||||
* Otherwise, derive defaults from the member's role.
|
||||
*/
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { meshPermission, meshMember, mesh, DEFAULT_PERMISSIONS } from "@turbostarter/db/schema/mesh";
|
||||
import type { PermissionKey } from "@turbostarter/db/schema/mesh";
|
||||
|
||||
export interface ResolvedPermissions {
|
||||
canInvite: boolean;
|
||||
canDeployMcp: boolean;
|
||||
canManageFiles: boolean;
|
||||
canManageVault: boolean;
|
||||
canManageWatches: boolean;
|
||||
canManageWebhooks: boolean;
|
||||
canWriteState: boolean;
|
||||
canSend: boolean;
|
||||
canUseTools: boolean;
|
||||
canDeleteMesh: boolean;
|
||||
canManagePermissions: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective permissions for a member in a mesh.
|
||||
* Checks for explicit permission row, falls back to role defaults.
|
||||
*/
|
||||
export async function getPermissions(meshId: string, memberId: string): Promise<ResolvedPermissions> {
|
||||
// Get the explicit permission row if it exists
|
||||
const [perm] = await db.select().from(meshPermission)
|
||||
.where(and(eq(meshPermission.meshId, meshId), eq(meshPermission.memberId, memberId)))
|
||||
.limit(1);
|
||||
|
||||
if (perm) {
|
||||
return {
|
||||
canInvite: perm.canInvite,
|
||||
canDeployMcp: perm.canDeployMcp,
|
||||
canManageFiles: perm.canManageFiles,
|
||||
canManageVault: perm.canManageVault,
|
||||
canManageWatches: perm.canManageWatches,
|
||||
canManageWebhooks: perm.canManageWebhooks,
|
||||
canWriteState: perm.canWriteState,
|
||||
canSend: perm.canSend,
|
||||
canUseTools: perm.canUseTools,
|
||||
canDeleteMesh: perm.canDeleteMesh,
|
||||
canManagePermissions: perm.canManagePermissions,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to role-based defaults
|
||||
const [member] = await db.select().from(meshMember)
|
||||
.where(eq(meshMember.id, memberId))
|
||||
.limit(1);
|
||||
|
||||
if (!member) return DEFAULT_PERMISSIONS.member;
|
||||
|
||||
// Check if member is mesh owner
|
||||
const [m] = await db.select().from(mesh)
|
||||
.where(eq(mesh.id, meshId))
|
||||
.limit(1);
|
||||
|
||||
if (m && m.ownerUserId && member.userId === m.ownerUserId) {
|
||||
return DEFAULT_PERMISSIONS.owner;
|
||||
}
|
||||
|
||||
return DEFAULT_PERMISSIONS[member.role] ?? DEFAULT_PERMISSIONS.member;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a single permission for a member.
|
||||
* Returns true if allowed, false if denied.
|
||||
*/
|
||||
export async function checkPermission(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
permission: PermissionKey,
|
||||
): Promise<boolean> {
|
||||
const perms = await getPermissions(meshId, memberId);
|
||||
return perms[permission];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set explicit permissions for a member (partial update).
|
||||
* Creates the row if it doesn't exist.
|
||||
*/
|
||||
export async function setPermissions(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
updates: Partial<ResolvedPermissions>,
|
||||
): Promise<void> {
|
||||
const [existing] = await db.select().from(meshPermission)
|
||||
.where(and(eq(meshPermission.meshId, meshId), eq(meshPermission.memberId, memberId)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db.update(meshPermission)
|
||||
.set({ ...updates, updatedAt: new Date() })
|
||||
.where(eq(meshPermission.id, existing.id));
|
||||
} else {
|
||||
// Get role defaults first, then overlay updates
|
||||
const defaults = await getPermissions(meshId, memberId);
|
||||
await db.insert(meshPermission).values({
|
||||
meshId,
|
||||
memberId,
|
||||
...defaults,
|
||||
...updates,
|
||||
});
|
||||
}
|
||||
}
|
||||
788
apps/broker/src/service-manager.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
/**
|
||||
* Service Manager — lifecycle management for mesh-deployed MCP servers.
|
||||
*
|
||||
* Each deployed MCP server runs as a child process with its own stdio pipe.
|
||||
* The manager spawns, monitors, restarts, and routes tool calls to them.
|
||||
*
|
||||
* In production: child processes run inside a Docker container (one per mesh).
|
||||
* In dev: child processes run directly on the broker host.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** MCP tool definition returned by tools/list. */
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Per-service deploy-time configuration. */
|
||||
export interface ServiceConfig {
|
||||
env?: Record<string, string>;
|
||||
memory_mb?: number;
|
||||
cpus?: number;
|
||||
network_allow?: string[];
|
||||
runtime?: "node" | "python" | "bun";
|
||||
}
|
||||
|
||||
/** Observable lifecycle states. */
|
||||
export type ServiceStatus =
|
||||
| "building"
|
||||
| "installing"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "failed"
|
||||
| "crashed"
|
||||
| "restarting";
|
||||
|
||||
/** Internal bookkeeping for a spawned service. */
|
||||
interface ManagedService {
|
||||
name: string;
|
||||
meshId: string;
|
||||
process: ChildProcess | null;
|
||||
tools: ToolDef[];
|
||||
status: ServiceStatus;
|
||||
config: ServiceConfig;
|
||||
sourcePath: string;
|
||||
runtime: "node" | "python" | "bun";
|
||||
restartCount: number;
|
||||
maxRestarts: number;
|
||||
healthFailures: number;
|
||||
logBuffer: string[]; // ring buffer, max LOG_BUFFER_SIZE
|
||||
pendingCalls: Map<
|
||||
string,
|
||||
{
|
||||
resolve: (result: { result?: unknown; error?: string }) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
>;
|
||||
pid?: number;
|
||||
startedAt?: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOG_BUFFER_SIZE = 1000;
|
||||
const HEALTH_INTERVAL_MS = 30_000;
|
||||
const HEALTH_TIMEOUT_MS = 5_000;
|
||||
const MAX_HEALTH_FAILURES = 3;
|
||||
const DEFAULT_MAX_RESTARTS = 5;
|
||||
const CALL_TIMEOUT_MS = 25_000;
|
||||
const SERVICES_BASE_DIR =
|
||||
process.env.CLAUDEMESH_SERVICES_DIR ?? "/var/claudemesh/services";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const services = new Map<string, ManagedService>(); // keyed by "meshId:serviceName"
|
||||
let healthTimer: NodeJS.Timer | null = null;
|
||||
|
||||
function serviceKey(meshId: string, name: string): string {
|
||||
return `${meshId}:${name}`;
|
||||
}
|
||||
|
||||
/** Validate service name: alphanumeric, hyphens, underscores only. No path traversal. */
|
||||
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
||||
|
||||
export function validateServiceName(name: string): string | null {
|
||||
if (!SAFE_NAME_RE.test(name)) {
|
||||
return "service name must be 1-64 chars, alphanumeric/hyphens/underscores, starting with alphanumeric";
|
||||
}
|
||||
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
|
||||
return "service name must not contain path separators";
|
||||
}
|
||||
return null; // valid
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect the runtime for a service based on its source directory contents.
|
||||
*
|
||||
* Priority: bun (lockfile/config) > node (package.json) > python
|
||||
* (pyproject.toml / requirements.txt). Falls back to node.
|
||||
*/
|
||||
export function detectRuntime(sourcePath: string): "node" | "python" | "bun" {
|
||||
if (
|
||||
existsSync(join(sourcePath, "bun.lockb")) ||
|
||||
existsSync(join(sourcePath, "bunfig.toml"))
|
||||
) {
|
||||
return "bun";
|
||||
}
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
return "node";
|
||||
}
|
||||
if (
|
||||
existsSync(join(sourcePath, "pyproject.toml")) ||
|
||||
existsSync(join(sourcePath, "requirements.txt"))
|
||||
) {
|
||||
return "python";
|
||||
}
|
||||
return "node"; // default
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function detectEntry(
|
||||
sourcePath: string,
|
||||
runtime: "node" | "python" | "bun",
|
||||
): { command: string; args: string[] } {
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
for (const entry of [
|
||||
"server.py",
|
||||
"src/server.py",
|
||||
"main.py",
|
||||
"src/main.py",
|
||||
]) {
|
||||
if (existsSync(join(sourcePath, entry))) {
|
||||
return { command: "python", args: [entry] };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (existsSync(join(sourcePath, "pyproject.toml"))) {
|
||||
return { command: "python", args: ["-m", "server"] };
|
||||
}
|
||||
return { command: "python", args: ["server.py"] };
|
||||
}
|
||||
|
||||
// Node / Bun
|
||||
const cmd = runtime === "bun" ? "bun" : "node";
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(sourcePath, "package.json"), "utf-8"),
|
||||
);
|
||||
if (pkg.main) return { command: cmd, args: [pkg.main] };
|
||||
if (pkg.bin) {
|
||||
const bin =
|
||||
typeof pkg.bin === "string"
|
||||
? pkg.bin
|
||||
: (Object.values(pkg.bin)[0] as string);
|
||||
if (bin) return { command: cmd, args: [bin] };
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
}
|
||||
|
||||
// Common entry points
|
||||
for (const entry of [
|
||||
"dist/index.js",
|
||||
"src/index.js",
|
||||
"src/index.ts",
|
||||
"index.js",
|
||||
]) {
|
||||
if (existsSync(join(sourcePath, entry))) {
|
||||
return { command: cmd, args: [entry] };
|
||||
}
|
||||
}
|
||||
|
||||
return { command: cmd, args: ["src/index.js"] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Install dependencies for a service. Resolves on success, rejects with
|
||||
* the tail of stderr on failure.
|
||||
*/
|
||||
export async function installDeps(
|
||||
sourcePath: string,
|
||||
runtime: "node" | "python" | "bun",
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cmd: string;
|
||||
let args: string[];
|
||||
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
cmd = "pip";
|
||||
args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
|
||||
} else {
|
||||
cmd = "pip";
|
||||
args = ["install", "--no-cache-dir", "."];
|
||||
}
|
||||
} else if (runtime === "bun") {
|
||||
cmd = "bun";
|
||||
args = ["install"];
|
||||
} else {
|
||||
cmd = "npm";
|
||||
args = ["install", "--production", "--legacy-peer-deps"];
|
||||
}
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: sourcePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (d: Buffer) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(
|
||||
`${cmd} install failed (exit ${code}): ${stderr.slice(-500)}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log ring buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function appendLog(svc: ManagedService, line: string): void {
|
||||
svc.logBuffer.push(`${new Date().toISOString()} ${line}`);
|
||||
if (svc.logBuffer.length > LOG_BUFFER_SIZE) {
|
||||
svc.logBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP JSON-RPC helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let callIdCounter = 0;
|
||||
|
||||
function sendMcpRequest(
|
||||
svc: ManagedService,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
): Promise<{ result?: unknown; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
if (!svc.process || !svc.process.stdin?.writable) {
|
||||
resolve({ error: "service not running" });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `call_${++callIdCounter}`;
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
svc.pendingCalls.delete(id);
|
||||
resolve({ error: `tool call timed out after ${CALL_TIMEOUT_MS}ms` });
|
||||
}, CALL_TIMEOUT_MS);
|
||||
|
||||
svc.pendingCalls.set(id, { resolve, timer });
|
||||
|
||||
try {
|
||||
svc.process.stdin!.write(JSON.stringify(request) + "\n");
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
svc.pendingCalls.delete(id);
|
||||
resolve({
|
||||
error: `write failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize MCP server (handshake + tool discovery)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function initializeMcp(svc: ManagedService): Promise<ToolDef[]> {
|
||||
// MCP initialize handshake
|
||||
const initResult = await sendMcpRequest(svc, "initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
|
||||
});
|
||||
|
||||
if (initResult.error) {
|
||||
throw new Error(`MCP initialize failed: ${initResult.error}`);
|
||||
}
|
||||
|
||||
// Send initialized notification (no response expected)
|
||||
if (svc.process?.stdin?.writable) {
|
||||
svc.process.stdin.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch tool list
|
||||
const toolsResult = await sendMcpRequest(svc, "tools/list", {});
|
||||
if (toolsResult.error) {
|
||||
throw new Error(`tools/list failed: ${toolsResult.error}`);
|
||||
}
|
||||
|
||||
const result = toolsResult.result as { tools?: ToolDef[] } | undefined;
|
||||
return result?.tools ?? [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn an MCP server child process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function spawnService(svc: ManagedService): void {
|
||||
const { command, args } = detectEntry(svc.sourcePath, svc.runtime);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
...(svc.config.env ?? {}),
|
||||
NODE_ENV: "production",
|
||||
};
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: svc.sourcePath,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env,
|
||||
});
|
||||
|
||||
svc.process = child;
|
||||
svc.pid = child.pid;
|
||||
svc.startedAt = new Date();
|
||||
svc.status = "running";
|
||||
svc.healthFailures = 0;
|
||||
|
||||
// Read MCP JSON-RPC responses from stdout
|
||||
const rl = createInterface({ input: child.stdout! });
|
||||
rl.on("line", (line) => {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && svc.pendingCalls.has(String(msg.id))) {
|
||||
const pending = svc.pendingCalls.get(String(msg.id))!;
|
||||
clearTimeout(pending.timer);
|
||||
svc.pendingCalls.delete(String(msg.id));
|
||||
if (msg.error) {
|
||||
pending.resolve({
|
||||
error: msg.error.message ?? JSON.stringify(msg.error),
|
||||
});
|
||||
} else {
|
||||
pending.resolve({ result: msg.result });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — treat as log output
|
||||
appendLog(svc, `[stdout] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Capture stderr as logs
|
||||
const stderrRl = createInterface({ input: child.stderr! });
|
||||
stderrRl.on("line", (line) => {
|
||||
appendLog(svc, `[stderr] ${line}`);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
log.warn("service exited", {
|
||||
service: svc.name,
|
||||
mesh_id: svc.meshId,
|
||||
code,
|
||||
signal,
|
||||
restarts: svc.restartCount,
|
||||
});
|
||||
|
||||
// Reject all pending calls
|
||||
for (const [, pending] of svc.pendingCalls) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({ error: "service crashed" });
|
||||
}
|
||||
svc.pendingCalls.clear();
|
||||
svc.process = null;
|
||||
svc.pid = undefined;
|
||||
|
||||
// Auto-restart if under limit
|
||||
if (svc.status === "running" && svc.restartCount < svc.maxRestarts) {
|
||||
svc.restartCount++;
|
||||
svc.status = "restarting";
|
||||
log.info("auto-restarting service", {
|
||||
service: svc.name,
|
||||
attempt: svc.restartCount,
|
||||
});
|
||||
setTimeout(() => spawnService(svc), 1000 * svc.restartCount); // backoff
|
||||
} else if (svc.status === "running") {
|
||||
svc.status = "crashed";
|
||||
log.error("service max restarts exceeded", {
|
||||
service: svc.name,
|
||||
restarts: svc.restartCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
log.error("service spawn error", {
|
||||
service: svc.name,
|
||||
error: err.message,
|
||||
});
|
||||
svc.status = "failed";
|
||||
});
|
||||
|
||||
log.info("service spawned", {
|
||||
service: svc.name,
|
||||
mesh_id: svc.meshId,
|
||||
pid: child.pid,
|
||||
command,
|
||||
args,
|
||||
runtime: svc.runtime,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deploy (or redeploy) an MCP server.
|
||||
*
|
||||
* Installs dependencies, spawns the child process, runs the MCP
|
||||
* initialize handshake, and returns the discovered tool list.
|
||||
*/
|
||||
export async function deploy(opts: {
|
||||
meshId: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
config: ServiceConfig;
|
||||
resolvedEnv?: Record<string, string>;
|
||||
}): Promise<{ tools: ToolDef[]; status: ServiceStatus }> {
|
||||
const key = serviceKey(opts.meshId, opts.name);
|
||||
|
||||
// Kill existing if redeploying
|
||||
const existing = services.get(key);
|
||||
if (existing?.process) {
|
||||
existing.process.kill("SIGTERM");
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
const runtime = opts.config.runtime ?? detectRuntime(opts.sourcePath);
|
||||
|
||||
const svc: ManagedService = {
|
||||
name: opts.name,
|
||||
meshId: opts.meshId,
|
||||
process: null,
|
||||
tools: [],
|
||||
status: "installing",
|
||||
config: {
|
||||
...opts.config,
|
||||
env: { ...(opts.config.env ?? {}), ...(opts.resolvedEnv ?? {}) },
|
||||
},
|
||||
sourcePath: opts.sourcePath,
|
||||
runtime,
|
||||
restartCount: 0,
|
||||
maxRestarts: DEFAULT_MAX_RESTARTS,
|
||||
healthFailures: 0,
|
||||
logBuffer: [],
|
||||
pendingCalls: new Map(),
|
||||
};
|
||||
|
||||
services.set(key, svc);
|
||||
|
||||
// Install dependencies
|
||||
try {
|
||||
await installDeps(opts.sourcePath, runtime);
|
||||
} catch (e) {
|
||||
svc.status = "failed";
|
||||
appendLog(
|
||||
svc,
|
||||
`Install failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Spawn and initialize
|
||||
spawnService(svc);
|
||||
|
||||
// Wait a moment for the process to start
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Get tool list via MCP initialize handshake
|
||||
try {
|
||||
svc.tools = await initializeMcp(svc);
|
||||
log.info("service deployed", {
|
||||
service: opts.name,
|
||||
mesh_id: opts.meshId,
|
||||
tools: svc.tools.length,
|
||||
runtime,
|
||||
});
|
||||
} catch (e) {
|
||||
svc.status = "failed";
|
||||
appendLog(
|
||||
svc,
|
||||
`MCP init failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { tools: svc.tools, status: svc.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Undeploy a running service. Sends SIGTERM, waits for graceful exit
|
||||
* (up to 10 s), then SIGKILL. All pending tool calls are rejected.
|
||||
*/
|
||||
export async function undeploy(meshId: string, name: string): Promise<void> {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return;
|
||||
|
||||
svc.status = "stopped";
|
||||
if (svc.process) {
|
||||
svc.process.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
svc.process?.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 10_000);
|
||||
svc.process?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reject pending calls
|
||||
for (const [, pending] of svc.pendingCalls) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({ error: "service undeployed" });
|
||||
}
|
||||
|
||||
services.delete(key);
|
||||
log.info("service undeployed", { service: name, mesh_id: meshId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a tool call to the named service. Returns the MCP response
|
||||
* payload or an error string.
|
||||
*/
|
||||
export async function callTool(
|
||||
meshId: string,
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<{ result?: unknown; error?: string }> {
|
||||
const key = serviceKey(meshId, serverName);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return { error: `service "${serverName}" not found` };
|
||||
if (svc.status !== "running")
|
||||
return { error: `service "${serverName}" is ${svc.status}` };
|
||||
if (!svc.process)
|
||||
return { error: `service "${serverName}" has no running process` };
|
||||
|
||||
return sendMcpRequest(svc, "tools/call", { name: toolName, arguments: args });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last N log lines for a service (from its ring buffer).
|
||||
*/
|
||||
export function getLogs(meshId: string, name: string, lines = 50): string[] {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return [];
|
||||
return svc.logBuffer.slice(-Math.min(lines, LOG_BUFFER_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current status, PID, restart count, tool list, and uptime
|
||||
* for a single service. Returns null if the service doesn't exist.
|
||||
*/
|
||||
export function getStatus(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): {
|
||||
status: ServiceStatus;
|
||||
pid?: number;
|
||||
restartCount: number;
|
||||
tools: ToolDef[];
|
||||
startedAt?: string;
|
||||
} | null {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return null;
|
||||
return {
|
||||
status: svc.status,
|
||||
pid: svc.pid,
|
||||
restartCount: svc.restartCount,
|
||||
tools: svc.tools,
|
||||
startedAt: svc.startedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the tool definitions for a service, or an empty array if the
|
||||
* service doesn't exist.
|
||||
*/
|
||||
export function getTools(meshId: string, name: string): ToolDef[] {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
return svc?.tools ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all services belonging to a mesh with summary info.
|
||||
*/
|
||||
export function listServices(
|
||||
meshId: string,
|
||||
): Array<{
|
||||
name: string;
|
||||
status: ServiceStatus;
|
||||
toolCount: number;
|
||||
runtime: string;
|
||||
restartCount: number;
|
||||
pid?: number;
|
||||
}> {
|
||||
const result: Array<{
|
||||
name: string;
|
||||
status: ServiceStatus;
|
||||
toolCount: number;
|
||||
runtime: string;
|
||||
restartCount: number;
|
||||
pid?: number;
|
||||
}> = [];
|
||||
for (const [key, svc] of services) {
|
||||
if (!key.startsWith(`${meshId}:`)) continue;
|
||||
result.push({
|
||||
name: svc.name,
|
||||
status: svc.status,
|
||||
toolCount: svc.tools.length,
|
||||
runtime: svc.runtime,
|
||||
restartCount: svc.restartCount,
|
||||
pid: svc.pid,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function healthCheckAll(): Promise<void> {
|
||||
for (const [, svc] of services) {
|
||||
if (svc.status !== "running" || !svc.process) continue;
|
||||
|
||||
const result = await sendMcpRequest(svc, "ping", {});
|
||||
if (result.error) {
|
||||
svc.healthFailures++;
|
||||
log.warn("health check failed", {
|
||||
service: svc.name,
|
||||
failures: svc.healthFailures,
|
||||
error: result.error,
|
||||
});
|
||||
if (svc.healthFailures >= MAX_HEALTH_FAILURES) {
|
||||
log.error("health check threshold exceeded, restarting", {
|
||||
service: svc.name,
|
||||
});
|
||||
svc.process.kill("SIGTERM");
|
||||
// exit handler will trigger auto-restart
|
||||
}
|
||||
} else {
|
||||
svc.healthFailures = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the periodic health check loop (30 s interval). No-op if already running. */
|
||||
export function startHealthChecks(): void {
|
||||
if (healthTimer) return;
|
||||
healthTimer = setInterval(healthCheckAll, HEALTH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop the periodic health check loop. */
|
||||
export function stopHealthChecks(): void {
|
||||
if (healthTimer) {
|
||||
clearInterval(healthTimer);
|
||||
healthTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restore all services on broker boot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Re-deploy every persisted service record. Called once at broker startup
|
||||
* to bring services back after a restart. Failures are logged but don't
|
||||
* prevent other services from restoring.
|
||||
*/
|
||||
export async function restoreAll(
|
||||
getServiceRecords: () => Promise<
|
||||
Array<{
|
||||
meshId: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
config: ServiceConfig;
|
||||
resolvedEnv?: Record<string, string>;
|
||||
}>
|
||||
>,
|
||||
): Promise<void> {
|
||||
const records = await getServiceRecords();
|
||||
log.info("restoring services", { count: records.length });
|
||||
|
||||
for (const record of records) {
|
||||
try {
|
||||
await deploy({
|
||||
meshId: record.meshId,
|
||||
name: record.name,
|
||||
sourcePath: record.sourcePath,
|
||||
config: record.config,
|
||||
resolvedEnv: record.resolvedEnv,
|
||||
});
|
||||
log.info("service restored", {
|
||||
service: record.name,
|
||||
mesh_id: record.meshId,
|
||||
});
|
||||
} catch (e) {
|
||||
log.error("service restore failed", {
|
||||
service: record.name,
|
||||
mesh_id: record.meshId,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
startHealthChecks();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gracefully shut down all running services. Stops health checks, sends
|
||||
* SIGTERM to every child, waits for exit, then clears the registry.
|
||||
*/
|
||||
export async function shutdownAll(): Promise<void> {
|
||||
stopHealthChecks();
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [, svc] of services) {
|
||||
if (svc.process) {
|
||||
svc.status = "stopped";
|
||||
promises.push(undeploy(svc.meshId, svc.name));
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
services.clear();
|
||||
}
|
||||
424
apps/broker/src/telegram-ai.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Claude-powered natural language processing for Telegram mesh interactions.
|
||||
*
|
||||
* Uses Claude Haiku 4.5 with tool calling to interpret user intent
|
||||
* and map to mesh operations. Destructive/social actions require
|
||||
* confirmation via Telegram inline buttons.
|
||||
*/
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { env } from "./env";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AiTool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AiToolCall {
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AiResult {
|
||||
type: "text" | "tool_call" | "error";
|
||||
text?: string;
|
||||
toolCall?: AiToolCall;
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOOLS: AiTool[] = [
|
||||
{
|
||||
name: "send_message",
|
||||
description: "Send a message to a peer in the mesh. Use when the user wants to tell, ask, or communicate something to a specific person or group.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
to: { type: "string", description: "Peer name, @group, or * for broadcast" },
|
||||
message: { type: "string", description: "The message content" },
|
||||
priority: { type: "string", enum: ["now", "next", "low"], description: "Delivery priority (default: next)" },
|
||||
},
|
||||
required: ["to", "message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_peers",
|
||||
description: "List all connected peers in the mesh. Use when user asks who's online, who's available, or what everyone is doing.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_meshes",
|
||||
description: "List all meshes this Telegram chat is connected to. Use when user asks about their meshes, which meshes are available, or wants to see their workspace list.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remember",
|
||||
description: "Store a memory/note in the mesh's shared knowledge. Use when user wants to save information for later.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: { type: "string", description: "The content to remember" },
|
||||
tags: { type: "array", items: { type: "string" }, description: "Tags for categorization" },
|
||||
},
|
||||
required: ["content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recall",
|
||||
description: "Search the mesh's shared memory. Use when user asks about something that was previously stored.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_state",
|
||||
description: "Read a shared state value. Use when user asks about a specific key/variable.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string", description: "State key to read" },
|
||||
},
|
||||
required: ["key"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_state",
|
||||
description: "Write a shared state value. Use when user wants to set/update a key.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string", description: "State key" },
|
||||
value: { type: "string", description: "Value to set" },
|
||||
},
|
||||
required: ["key", "value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_mesh",
|
||||
description: "Create a new mesh. Use when user wants to create a new workspace/mesh.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Mesh name" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "share_mesh",
|
||||
description: "Generate an invite link or send an invite email. Use when user wants to invite someone to the mesh.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
email: { type: "string", description: "Email to invite (optional — if omitted, generates a link)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_services",
|
||||
description: "List all deployed MCP services and skills in the mesh. Use when user asks about available tools, services, MCPs, skills, or capabilities.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_commands",
|
||||
description: "Show available Telegram bot commands. Use when user asks what commands are available, what they can do, or asks for help.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Actions that need user confirmation before executing
|
||||
const CONFIRM_ACTIONS = new Set([
|
||||
"send_message",
|
||||
"create_mesh",
|
||||
"share_mesh",
|
||||
"set_state",
|
||||
"remember",
|
||||
]);
|
||||
|
||||
const SYSTEM_PROMPT = `You are the claudemesh Telegram assistant. You help users interact with their claudemesh peer network using natural language.
|
||||
|
||||
You have access to tools for mesh operations. When the user's intent maps to a tool, use it. When it's a general question or conversation, respond directly.
|
||||
|
||||
IMPORTANT: Always respond in the same language the user writes in. If they write in Spanish, respond in Spanish. If English, respond in English.
|
||||
|
||||
Key concepts:
|
||||
- A MESH is a workspace/group (like "flexicar", "alexis-mou"). This Telegram chat can be connected to multiple meshes.
|
||||
- A PEER is a person/agent connected to a mesh (like "Nedas", "Mou").
|
||||
- When user says "send to <mesh-name>", they mean BROADCAST to all peers in that mesh. Use send_message with to="*" — the system will route to the correct mesh.
|
||||
- When user says "send to <person-name>", they mean a direct message to that peer.
|
||||
|
||||
Rules:
|
||||
- Be concise — Telegram messages should be short
|
||||
- When sending messages to peers, preserve the user's tone and intent
|
||||
- If the target looks like a mesh name (matches one from context), broadcast to it
|
||||
- Never fabricate peer names — use list_peers to find real names
|
||||
- Default to the first connected mesh if not specified`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI Engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let client: Anthropic | null = null;
|
||||
|
||||
function getClient(): Anthropic {
|
||||
if (!client) {
|
||||
const apiKey = env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not configured");
|
||||
client = new Anthropic({ apiKey });
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation history (per chat, rolling window)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_HISTORY = 10;
|
||||
const HISTORY_TTL_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
const chatHistory = new Map<number, HistoryEntry[]>();
|
||||
|
||||
// Clean stale histories every 10 min
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [chatId, entries] of chatHistory) {
|
||||
const fresh = entries.filter(e => now - e.ts < HISTORY_TTL_MS);
|
||||
if (fresh.length === 0) chatHistory.delete(chatId);
|
||||
else chatHistory.set(chatId, fresh);
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
function getHistory(chatId: number): HistoryEntry[] {
|
||||
return chatHistory.get(chatId) ?? [];
|
||||
}
|
||||
|
||||
function pushHistory(chatId: number, role: "user" | "assistant", content: string): void {
|
||||
const entries = chatHistory.get(chatId) ?? [];
|
||||
entries.push({ role, content, ts: Date.now() });
|
||||
if (entries.length > MAX_HISTORY * 2) entries.splice(0, entries.length - MAX_HISTORY * 2);
|
||||
chatHistory.set(chatId, entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a tool result in conversation history so the AI knows what happened.
|
||||
*/
|
||||
export function recordToolResult(chatId: number, toolName: string, resultSummary: string): void {
|
||||
pushHistory(chatId, "assistant", `[Tool ${toolName} result]: ${resultSummary}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a natural language message through Claude and return the intent.
|
||||
*/
|
||||
export async function processMessage(
|
||||
chatId: number,
|
||||
userMessage: string,
|
||||
context: { meshSlug?: string; meshSlugs?: string[]; userName?: string; recentPeers?: string[] },
|
||||
): Promise<AiResult> {
|
||||
try {
|
||||
const anthropic = getClient();
|
||||
|
||||
// Record user message in history
|
||||
pushHistory(chatId, "user", userMessage);
|
||||
|
||||
const contextInfo = [
|
||||
context.meshSlugs?.length ? `Connected meshes: ${context.meshSlugs.join(", ")}` : context.meshSlug ? `Current mesh: ${context.meshSlug}` : null,
|
||||
context.userName ? `User's name: ${context.userName}` : null,
|
||||
context.recentPeers?.length ? `Known peers: ${context.recentPeers.join(", ")}` : null,
|
||||
].filter(Boolean).join(". ");
|
||||
|
||||
// Build message history for multi-turn context
|
||||
const history = getHistory(chatId);
|
||||
const messages: Array<{ role: "user" | "assistant"; content: string }> = [];
|
||||
for (const entry of history) {
|
||||
// Alternate roles — Claude API requires user/assistant alternation
|
||||
if (messages.length === 0 || messages[messages.length - 1]!.role !== entry.role) {
|
||||
messages.push({ role: entry.role, content: entry.content });
|
||||
} else {
|
||||
// Same role consecutive — merge into the last message
|
||||
messages[messages.length - 1]!.content += "\n" + entry.content;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure messages start with user and alternate
|
||||
if (messages.length > 0 && messages[0]!.role !== "user") {
|
||||
messages.shift();
|
||||
}
|
||||
|
||||
const response = await anthropic.messages.create({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
max_tokens: 500,
|
||||
system: SYSTEM_PROMPT + (contextInfo ? `\n\nContext: ${contextInfo}` : ""),
|
||||
tools: TOOLS as Anthropic.Messages.Tool[],
|
||||
messages,
|
||||
});
|
||||
|
||||
// Check for tool use
|
||||
for (const block of response.content) {
|
||||
if (block.type === "tool_use") {
|
||||
pushHistory(chatId, "assistant", `[Using tool: ${block.name}(${JSON.stringify(block.input).slice(0, 100)})]`);
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: { name: block.name, input: block.input as Record<string, unknown> },
|
||||
requiresConfirmation: CONFIRM_ACTIONS.has(block.name),
|
||||
};
|
||||
}
|
||||
if (block.type === "text") {
|
||||
pushHistory(chatId, "assistant", block.text);
|
||||
return { type: "text", text: block.text };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "text", text: "I'm not sure how to help with that." };
|
||||
} catch (err) {
|
||||
log.error("telegram-ai", { error: err instanceof Error ? err.message : String(err) });
|
||||
return { type: "error", text: "AI processing failed. Try a /command instead." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a tool call as a human-readable confirmation message for Telegram.
|
||||
*/
|
||||
export function formatConfirmation(toolCall: AiToolCall): string {
|
||||
const { name, input } = toolCall;
|
||||
|
||||
switch (name) {
|
||||
case "send_message":
|
||||
return `📤 <b>Send message to ${escHtml(String(input.to))}:</b>\n\n"${escHtml(String(input.message))}"\n\nPriority: ${input.priority ?? "next"}`;
|
||||
|
||||
case "create_mesh":
|
||||
return `🔧 <b>Create mesh:</b>\n\nName: ${escHtml(String(input.name))}`;
|
||||
|
||||
case "share_mesh":
|
||||
return input.email
|
||||
? `📧 <b>Send invite to:</b>\n\n${escHtml(String(input.email))}`
|
||||
: `🔗 <b>Generate invite link</b>`;
|
||||
|
||||
case "set_state":
|
||||
return `📝 <b>Set state:</b>\n\n<code>${escHtml(String(input.key))}</code> = <code>${escHtml(String(input.value))}</code>`;
|
||||
|
||||
case "remember":
|
||||
return `💾 <b>Remember:</b>\n\n"${escHtml(String(input.content))}"${input.tags ? `\nTags: ${(input.tags as string[]).join(", ")}` : ""}`;
|
||||
|
||||
default:
|
||||
return `⚙️ <b>${escHtml(name)}:</b>\n\n<pre>${escHtml(JSON.stringify(input, null, 2))}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a tool result as a Telegram reply.
|
||||
*/
|
||||
export function formatResult(toolName: string, result: unknown): string {
|
||||
switch (toolName) {
|
||||
case "send_message":
|
||||
return "✅ Message sent.";
|
||||
|
||||
case "list_peers": {
|
||||
const peers = result as Array<{ displayName: string; status: string; summary?: string }>;
|
||||
if (!peers || peers.length === 0) return "No peers online.";
|
||||
return "👥 <b>Online peers:</b>\n\n" + peers.map(p => {
|
||||
const icon = p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : p.status === "dnd" ? "🔴" : "⚪";
|
||||
return `${icon} <b>${escHtml(p.displayName)}</b>${p.summary ? ` — ${escHtml(p.summary)}` : ""}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
case "list_meshes": {
|
||||
const meshes = result as Array<{ slug: string; peers: number }>;
|
||||
if (!meshes || meshes.length === 0) return "No meshes connected. Use /connect to add one.";
|
||||
return "🔗 <b>Connected meshes:</b>\n\n" + meshes.map(m =>
|
||||
`• <b>${escHtml(m.slug)}</b> — ${m.peers} peer${m.peers !== 1 ? "s" : ""} online`
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
case "recall": {
|
||||
const memories = result as Array<{ content: string; tags: string[] }>;
|
||||
if (!memories || memories.length === 0) return "No memories found.";
|
||||
return "🧠 <b>Memories:</b>\n\n" + memories.map(m =>
|
||||
`• ${escHtml(m.content)}${m.tags.length ? ` <i>[${m.tags.join(", ")}]</i>` : ""}`
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
case "get_state": {
|
||||
const state = result as { key: string; value: unknown } | null;
|
||||
if (!state) return "Key not found.";
|
||||
return `📊 <code>${escHtml(state.key)}</code> = <code>${escHtml(String(state.value))}</code>`;
|
||||
}
|
||||
|
||||
case "remember":
|
||||
return "💾 Remembered.";
|
||||
|
||||
case "set_state":
|
||||
return "📝 State updated.";
|
||||
|
||||
case "create_mesh":
|
||||
return "✅ Mesh created.";
|
||||
|
||||
case "share_mesh":
|
||||
return typeof result === "string" ? `🔗 Invite: ${result}` : "✅ Invite sent.";
|
||||
|
||||
case "list_services": {
|
||||
const services = result as Array<{ name: string; type: string; tools: number; status: string }>;
|
||||
if (!services || services.length === 0) return "No services deployed in this mesh.";
|
||||
return "⚙️ <b>Mesh services:</b>\n\n" + services.map(s =>
|
||||
`• <b>${escHtml(s.name)}</b> (${s.type}) — ${s.tools} tool${s.tools !== 1 ? "s" : ""} [${s.status}]`
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
case "list_commands":
|
||||
return `📋 <b>Available commands:</b>
|
||||
|
||||
/connect — connect to a mesh
|
||||
/disconnect — disconnect from mesh
|
||||
/peers — list online peers
|
||||
/meshes — list connected meshes
|
||||
/dm @Name message — send direct message
|
||||
/broadcast message — send to all peers
|
||||
/status — connection status
|
||||
/help — show help
|
||||
|
||||
Or just type naturally:
|
||||
• "who's online?"
|
||||
• "tell Nedas the API is ready"
|
||||
• "list my meshes"
|
||||
• "what services are available?"`;
|
||||
|
||||
default:
|
||||
return `✅ Done: ${JSON.stringify(result)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export { CONFIRM_ACTIONS };
|
||||
1944
apps/broker/src/telegram-bridge.ts
Normal file
148
apps/broker/src/telegram-token.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* JWT utilities for Telegram bridge connections.
|
||||
*
|
||||
* When a user connects their Telegram chat to a mesh, the broker generates
|
||||
* a short-lived JWT containing mesh credentials. The Telegram bot decodes
|
||||
* this token to establish the connection.
|
||||
*
|
||||
* Pure-crypto implementation — no external JWT library.
|
||||
* Tokens are URL-safe (base64url) for use as Telegram deep link parameters.
|
||||
*
|
||||
* IMPORTANT: The JWT payload contains the member's secretKey.
|
||||
* Never log the token or its decoded payload.
|
||||
*/
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface TelegramConnectPayload {
|
||||
meshId: string;
|
||||
meshSlug: string;
|
||||
memberId: string;
|
||||
pubkey: string;
|
||||
secretKey: string; // ed25519 secret key — sensitive
|
||||
createdBy: string; // Dashboard userId or CLI memberId
|
||||
}
|
||||
|
||||
interface JwtClaims extends TelegramConnectPayload {
|
||||
iss: string;
|
||||
sub: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function base64url(data: string): string {
|
||||
return Buffer.from(data).toString("base64url");
|
||||
}
|
||||
|
||||
function base64urlDecode(str: string): string {
|
||||
return Buffer.from(str, "base64url").toString("utf-8");
|
||||
}
|
||||
|
||||
function sign(input: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(input).digest("base64url");
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
const JWT_HEADER = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const TOKEN_TTL_SECONDS = 15 * 60; // 15 minutes
|
||||
|
||||
/**
|
||||
* Create a signed JWT containing Telegram connect credentials.
|
||||
* Expires in 15 minutes.
|
||||
*/
|
||||
export function generateTelegramConnectToken(
|
||||
payload: TelegramConnectPayload,
|
||||
secret: string,
|
||||
): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const claims: JwtClaims = {
|
||||
...payload,
|
||||
iss: "claudemesh-broker",
|
||||
sub: "telegram-connect",
|
||||
iat: now,
|
||||
exp: now + TOKEN_TTL_SECONDS,
|
||||
};
|
||||
|
||||
const encodedPayload = base64url(JSON.stringify(claims));
|
||||
const signingInput = `${JWT_HEADER}.${encodedPayload}`;
|
||||
const signature = sign(signingInput, secret);
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and decode a Telegram connect JWT.
|
||||
* Returns the payload on success, or null on any failure
|
||||
* (bad signature, expired, wrong subject).
|
||||
*/
|
||||
export function validateTelegramConnectToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): TelegramConnectPayload | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
|
||||
|
||||
// Verify signature
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const expectedSignature = sign(signingInput, secret);
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
const a = Buffer.from(signatureB64);
|
||||
const b = Buffer.from(expectedSignature);
|
||||
if (a.length !== b.length) return null;
|
||||
const { timingSafeEqual } = require("node:crypto");
|
||||
if (!timingSafeEqual(a, b)) return null;
|
||||
|
||||
// Verify header algorithm
|
||||
const header = JSON.parse(base64urlDecode(headerB64));
|
||||
if (header.alg !== "HS256") return null;
|
||||
|
||||
// Decode and validate claims
|
||||
const claims: JwtClaims = JSON.parse(base64urlDecode(payloadB64));
|
||||
|
||||
// Check subject
|
||||
if (claims.sub !== "telegram-connect") return null;
|
||||
|
||||
// Check expiry
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (claims.exp < now) return null;
|
||||
|
||||
// Check iat not in the future (30s tolerance)
|
||||
if (claims.iat > now + 30) return null;
|
||||
|
||||
// Extract payload fields (strip JWT claims)
|
||||
const {
|
||||
meshId,
|
||||
meshSlug,
|
||||
memberId,
|
||||
pubkey,
|
||||
secretKey,
|
||||
createdBy,
|
||||
} = claims;
|
||||
|
||||
// Basic presence check
|
||||
if (!meshId || !meshSlug || !memberId || !pubkey || !secretKey || !createdBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { meshId, meshSlug, memberId, pubkey, secretKey, createdBy };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Telegram deep link that passes the JWT as start parameter.
|
||||
* Format: https://t.me/{botUsername}?start={token}
|
||||
*/
|
||||
export function generateDeepLink(token: string, botUsername: string): string {
|
||||
return `https://t.me/${botUsername}?start=${token}`;
|
||||
}
|
||||
@@ -57,6 +57,14 @@ export interface WSHelloMessage {
|
||||
sessionId: string;
|
||||
pid: number;
|
||||
cwd: string;
|
||||
/** OS hostname — used to detect same-machine peers for direct file access. */
|
||||
hostname?: string;
|
||||
/** Peer type: ai session, human user, or external connector. */
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
/** Channel the peer connected from (e.g. "claude-code", "telegram", "slack", "web"). */
|
||||
channel?: string;
|
||||
/** AI model identifier (e.g. "opus-4", "sonnet-4"). */
|
||||
model?: string;
|
||||
/** Initial groups to join on connect. */
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
/** ms epoch; broker rejects if outside ±60s of its own clock. */
|
||||
@@ -86,6 +94,13 @@ export interface WSPushMessage {
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
/** Optional semantic tag — "reminder" when delivered by the scheduler,
|
||||
* "system" for broker-originated topology events (peer join/leave). */
|
||||
subtype?: "reminder" | "system";
|
||||
/** Machine-readable event name (e.g. "peer_joined", "peer_left"). */
|
||||
event?: string;
|
||||
/** Structured payload for the event. */
|
||||
eventData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Client → broker: manual status override (dnd, forced idle). */
|
||||
@@ -105,6 +120,36 @@ export interface WSSetSummaryMessage {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
|
||||
/** Client → broker: toggle visibility in the mesh. */
|
||||
export interface WSSetVisibleMessage {
|
||||
type: "set_visible";
|
||||
visible: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: set public profile metadata. */
|
||||
export interface WSSetProfileMessage {
|
||||
type: "set_profile";
|
||||
avatar?: string; // emoji or URL
|
||||
title?: string; // short role label
|
||||
bio?: string; // one-liner
|
||||
capabilities?: string[]; // what I can help with
|
||||
_reqId?: string;
|
||||
}
|
||||
/** Client → broker: self-report resource usage stats. */
|
||||
export interface WSSetStatsMessage {
|
||||
type: "set_stats";
|
||||
stats: {
|
||||
messagesIn?: number;
|
||||
messagesOut?: number;
|
||||
toolCalls?: number;
|
||||
uptime?: number; // seconds since session start
|
||||
errors?: number;
|
||||
};
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: join a group with optional role. */
|
||||
export interface WSJoinGroupMessage {
|
||||
type: "join_group";
|
||||
@@ -161,6 +206,7 @@ export interface WSAckMessage {
|
||||
id: string; // echoes client-side correlation id
|
||||
messageId: string;
|
||||
queued: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: hello handshake acknowledgement. */
|
||||
@@ -168,6 +214,17 @@ export interface WSHelloAckMessage {
|
||||
type: "hello_ack";
|
||||
presenceId: string;
|
||||
memberDisplayName: string;
|
||||
/** True when the broker restored persisted state from a previous session. */
|
||||
restored?: boolean;
|
||||
/** Last summary set before disconnect (only when restored). */
|
||||
lastSummary?: string;
|
||||
/** ISO timestamp of last disconnect (only when restored). */
|
||||
lastSeenAt?: string;
|
||||
/** Restored groups from previous session (only when restored and hello had no groups). */
|
||||
restoredGroups?: Array<{ name: string; role?: string }>;
|
||||
/** Restored cumulative stats (only when restored). */
|
||||
restoredStats?: { messagesIn: number; messagesOut: number; toolCalls: number; errors: number };
|
||||
services?: Array<{ name: string; description: string; status: string; tools: Array<{ name: string; description: string; inputSchema: object }>; deployed_by: string }>;
|
||||
}
|
||||
|
||||
/** Broker → client: list of connected peers in the same mesh. */
|
||||
@@ -181,7 +238,27 @@ export interface WSPeersListMessage {
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
connectedAt: string;
|
||||
cwd?: string;
|
||||
hostname?: string;
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
channel?: string;
|
||||
model?: string;
|
||||
stats?: {
|
||||
messagesIn?: number;
|
||||
messagesOut?: number;
|
||||
toolCalls?: number;
|
||||
uptime?: number;
|
||||
errors?: number;
|
||||
};
|
||||
visible?: boolean;
|
||||
profile?: {
|
||||
avatar?: string;
|
||||
title?: string;
|
||||
bio?: string;
|
||||
capabilities?: string[];
|
||||
};
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: a state key was changed by another peer. */
|
||||
@@ -199,6 +276,7 @@ export interface WSStateResultMessage {
|
||||
value: unknown;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_state. */
|
||||
@@ -210,12 +288,14 @@ export interface WSStateListMessage {
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for a remember. */
|
||||
export interface WSMemoryStoredMessage {
|
||||
type: "memory_stored";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to recall. */
|
||||
@@ -228,6 +308,7 @@ export interface WSMemoryResultsMessage {
|
||||
rememberedBy: string;
|
||||
rememberedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Vector storage messages ---
|
||||
@@ -295,6 +376,13 @@ export interface WSMeshSchemaMessage {
|
||||
|
||||
// --- Vector/Graph response messages ---
|
||||
|
||||
/** Broker → client: confirmation that a vector point was stored. */
|
||||
export interface WSVectorStoredMessage {
|
||||
type: "vector_stored";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: vector search results. */
|
||||
export interface WSVectorResultsMessage {
|
||||
type: "vector_results";
|
||||
@@ -304,18 +392,21 @@ export interface WSVectorResultsMessage {
|
||||
score: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of vector collections. */
|
||||
export interface WSCollectionListMessage {
|
||||
type: "collection_list";
|
||||
collections: string[];
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: graph query results. */
|
||||
export interface WSGraphResultMessage {
|
||||
type: "graph_result";
|
||||
records: Array<Record<string, unknown>>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: mesh SQL query results. */
|
||||
@@ -324,6 +415,7 @@ export interface WSMeshQueryResultMessage {
|
||||
columns: string[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
rowCount: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: mesh schema introspection results. */
|
||||
@@ -333,6 +425,7 @@ export interface WSMeshSchemaResultMessage {
|
||||
name: string;
|
||||
columns: Array<{ name: string; type: string; nullable: boolean }>;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: get full mesh overview. */
|
||||
@@ -355,6 +448,7 @@ export interface WSMeshInfoResultMessage {
|
||||
collections: string[];
|
||||
yourName: string;
|
||||
yourGroups: Array<{ name: string; role?: string }>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: check delivery status of a message. */
|
||||
@@ -375,6 +469,7 @@ export interface WSMessageStatusResultMessage {
|
||||
pubkey: string;
|
||||
status: "delivered" | "held" | "disconnected";
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- File sharing messages ---
|
||||
@@ -404,12 +499,23 @@ export interface WSDeleteFileMessage {
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
/** Client → broker: grant a peer access to an encrypted file. */
|
||||
export interface WSGrantFileAccessMessage {
|
||||
type: "grant_file_access";
|
||||
fileId: string;
|
||||
peerPubkey: string;
|
||||
sealedKey: string;
|
||||
}
|
||||
|
||||
/** Broker → client: presigned URL for downloading a file. */
|
||||
export interface WSFileUrlMessage {
|
||||
type: "file_url";
|
||||
fileId: string;
|
||||
url: string;
|
||||
name: string;
|
||||
encrypted?: boolean;
|
||||
sealedKey?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of files in the mesh. */
|
||||
@@ -423,7 +529,17 @@ export interface WSFileListMessage {
|
||||
uploadedBy: string;
|
||||
uploadedAt: string;
|
||||
persistent: boolean;
|
||||
encrypted: boolean;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for grant_file_access. */
|
||||
export interface WSGrantFileAccessOkMessage {
|
||||
type: "grant_file_access_ok";
|
||||
fileId: string;
|
||||
peerPubkey: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: access log for a file. */
|
||||
@@ -434,6 +550,7 @@ export interface WSFileStatusResultMessage {
|
||||
peerName: string;
|
||||
accessedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Context sharing messages ---
|
||||
@@ -475,6 +592,7 @@ export interface WSContextResultsMessage {
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_contexts. */
|
||||
@@ -486,6 +604,7 @@ export interface WSContextListMessage {
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Task messages ---
|
||||
@@ -523,6 +642,7 @@ export interface WSListTasksMessage {
|
||||
export interface WSTaskCreatedMessage {
|
||||
type: "task_created";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_tasks, claim_task, complete_task. */
|
||||
@@ -539,6 +659,7 @@ export interface WSTaskListMessage {
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Stream messages ---
|
||||
@@ -578,6 +699,7 @@ export interface WSStreamCreatedMessage {
|
||||
type: "stream_created";
|
||||
id: string;
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: real-time data pushed from a stream. */
|
||||
@@ -588,6 +710,13 @@ export interface WSStreamDataMessage {
|
||||
publishedBy: string;
|
||||
}
|
||||
|
||||
/** Broker → client: confirmation that a stream subscription was registered. */
|
||||
export interface WSSubscribedMessage {
|
||||
type: "subscribed";
|
||||
stream: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_streams. */
|
||||
export interface WSStreamListMessage {
|
||||
type: "stream_list";
|
||||
@@ -598,6 +727,202 @@ export interface WSStreamListMessage {
|
||||
createdAt: string;
|
||||
subscriberCount: number;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- MCP proxy messages ---
|
||||
|
||||
/** Client → broker: register an MCP server with the mesh. */
|
||||
export interface WSMcpRegisterMessage {
|
||||
type: "mcp_register";
|
||||
serverName: string;
|
||||
description: string;
|
||||
tools: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }>;
|
||||
persistent?: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: unregister an MCP server. */
|
||||
export interface WSMcpUnregisterMessage {
|
||||
type: "mcp_unregister";
|
||||
serverName: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list all MCP servers in the mesh. */
|
||||
export interface WSMcpListMessage {
|
||||
type: "mcp_list";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: call a tool on a mesh-registered MCP server. */
|
||||
export interface WSMcpCallMessage {
|
||||
type: "mcp_call";
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: response to a forwarded MCP call. */
|
||||
export interface WSMcpCallResponseMessage {
|
||||
type: "mcp_call_response";
|
||||
callId: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for mcp_register. */
|
||||
export interface WSMcpRegisterAckMessage {
|
||||
type: "mcp_register_ack";
|
||||
serverName: string;
|
||||
toolCount: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of MCP servers in the mesh. */
|
||||
export interface WSMcpListResultMessage {
|
||||
type: "mcp_list_result";
|
||||
servers: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
hostedBy: string;
|
||||
tools: Array<{ name: string; description: string }>;
|
||||
online: boolean;
|
||||
offlineSince?: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: result of an MCP tool call. */
|
||||
export interface WSMcpCallResultMessage {
|
||||
type: "mcp_call_result";
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: forwarded MCP tool call to execute locally. */
|
||||
export interface WSMcpCallForwardMessage {
|
||||
type: "mcp_call_forward";
|
||||
callId: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
callerName: string;
|
||||
}
|
||||
|
||||
// --- Webhook CRUD messages ---
|
||||
|
||||
/** Client → broker: create an inbound webhook. */
|
||||
export interface WSCreateWebhookMessage {
|
||||
type: "create_webhook";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list webhooks for the mesh. */
|
||||
export interface WSListWebhooksMessage {
|
||||
type: "list_webhooks";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: deactivate a webhook. */
|
||||
export interface WSDeleteWebhookMessage {
|
||||
type: "delete_webhook";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for create_webhook. */
|
||||
export interface WSWebhookAckMessage {
|
||||
type: "webhook_ack";
|
||||
name: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of webhooks for the mesh. */
|
||||
export interface WSWebhookListMessage {
|
||||
type: "webhook_list";
|
||||
webhooks: Array<{ name: string; url: string; active: boolean; createdAt: string }>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Peer file sharing (relay) messages ---
|
||||
|
||||
/** Client → broker: request a file from a peer's local filesystem. */
|
||||
export interface WSPeerFileRequestMessage {
|
||||
type: "peer_file_request";
|
||||
targetPubkey: string;
|
||||
filePath: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → target peer: forwarded file request from another peer. */
|
||||
export interface WSPeerFileRequestForwardMessage {
|
||||
type: "peer_file_request_forward";
|
||||
requesterPubkey: string;
|
||||
filePath: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Target peer → broker: response with file content (or error). */
|
||||
export interface WSPeerFileResponseMessage {
|
||||
type: "peer_file_response";
|
||||
requesterPubkey: string;
|
||||
filePath: string;
|
||||
content?: string; // base64 encoded
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → requester: forwarded file content from target peer. */
|
||||
export interface WSPeerFileResponseForwardMessage {
|
||||
type: "peer_file_response_forward";
|
||||
filePath: string;
|
||||
content?: string;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: request a directory listing from a peer. */
|
||||
export interface WSPeerDirRequestMessage {
|
||||
type: "peer_dir_request";
|
||||
targetPubkey: string;
|
||||
dirPath: string;
|
||||
pattern?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → target peer: forwarded directory listing request. */
|
||||
export interface WSPeerDirRequestForwardMessage {
|
||||
type: "peer_dir_request_forward";
|
||||
requesterPubkey: string;
|
||||
dirPath: string;
|
||||
pattern?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Target peer → broker: directory listing response. */
|
||||
export interface WSPeerDirResponseMessage {
|
||||
type: "peer_dir_response";
|
||||
requesterPubkey: string;
|
||||
dirPath: string;
|
||||
entries?: string[];
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → requester: forwarded directory listing from target peer. */
|
||||
export interface WSPeerDirResponseForwardMessage {
|
||||
type: "peer_dir_response_forward";
|
||||
dirPath: string;
|
||||
entries?: string[];
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: structured error. */
|
||||
@@ -606,14 +931,200 @@ export interface WSErrorMessage {
|
||||
code: string;
|
||||
message: string;
|
||||
id?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Audit log messages ---
|
||||
|
||||
/** Client → broker: query paginated audit entries for a mesh. */
|
||||
export interface WSAuditQueryMessage {
|
||||
type: "audit_query";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
eventType?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: verify the hash chain for the mesh audit log. */
|
||||
export interface WSAuditVerifyMessage {
|
||||
type: "audit_verify";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: paginated audit log entries. */
|
||||
export interface WSAuditResultMessage {
|
||||
type: "audit_result";
|
||||
entries: Array<{
|
||||
id: number;
|
||||
eventType: string;
|
||||
actor: string;
|
||||
payload: Record<string, unknown>;
|
||||
hash: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
total: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: result of hash chain verification. */
|
||||
export interface WSAuditVerifyResultMessage {
|
||||
type: "audit_verify_result";
|
||||
valid: boolean;
|
||||
entries: number;
|
||||
brokenAt?: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Simulation clock messages ---
|
||||
|
||||
/** Client → broker: set the simulation clock speed. */
|
||||
export interface WSSetClockMessage {
|
||||
type: "set_clock";
|
||||
speed: number; // multiplier: 1, 2, 5, 10, 50, 100
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: pause the simulation clock. */
|
||||
export interface WSPauseClockMessage {
|
||||
type: "pause_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: resume a paused simulation clock. */
|
||||
export interface WSResumeClockMessage {
|
||||
type: "resume_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: get current clock status. */
|
||||
export interface WSGetClockMessage {
|
||||
type: "get_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: current simulation clock status. */
|
||||
export interface WSClockStatusMessage {
|
||||
type: "clock_status";
|
||||
speed: number;
|
||||
paused: boolean;
|
||||
tick: number;
|
||||
simTime: string; // ISO timestamp
|
||||
startedAt: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Scheduled messages ---
|
||||
|
||||
/** Client → broker: schedule a message for future delivery. */
|
||||
export interface WSScheduleMessage {
|
||||
type: "schedule";
|
||||
to: string;
|
||||
message: string;
|
||||
/** Unix timestamp (ms) when to deliver. Ignored for cron schedules. */
|
||||
deliverAt: number;
|
||||
/** Optional semantic tag — "reminder" surfaces differently to the receiver. */
|
||||
subtype?: "reminder";
|
||||
/** Standard 5-field cron expression for recurring delivery. */
|
||||
cron?: string;
|
||||
/** Whether this is a recurring schedule. Implied true when `cron` is set. */
|
||||
recurring?: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list pending scheduled messages for this member. */
|
||||
export interface WSListScheduledMessage {
|
||||
type: "list_scheduled";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: cancel a scheduled message by id. */
|
||||
export interface WSCancelScheduledMessage {
|
||||
type: "cancel_scheduled";
|
||||
scheduledId: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for schedule, carries the assigned id. */
|
||||
export interface WSScheduledAckMessage {
|
||||
type: "scheduled_ack";
|
||||
scheduledId: string;
|
||||
deliverAt: number;
|
||||
/** Present for cron schedules — echoes the expression. */
|
||||
cron?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of pending scheduled messages. */
|
||||
export interface WSScheduledListMessage {
|
||||
type: "scheduled_list";
|
||||
messages: Array<{
|
||||
id: string;
|
||||
to: string;
|
||||
message: string;
|
||||
deliverAt: number;
|
||||
createdAt: number;
|
||||
/** Present for cron/recurring entries. */
|
||||
cron?: string;
|
||||
/** Number of times the cron entry has fired so far. */
|
||||
firedCount?: number;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: cancel confirmation. */
|
||||
export interface WSCancelScheduledAckMessage {
|
||||
type: "cancel_scheduled_ack";
|
||||
scheduledId: string;
|
||||
ok: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: deploy an MCP server from zip or git. */
|
||||
export interface WSMcpDeployMessage { type: "mcp_deploy"; server_name: string; source: { type: "zip"; file_id: string } | { type: "git"; url: string; branch?: string; auth?: string }; config?: { env?: Record<string, string>; memory_mb?: number; cpus?: number; network_allow?: string[]; runtime?: "node" | "python" | "bun" }; scope?: "peer" | "mesh" | { peers: string[] } | { group: string } | { groups: string[] } | { role: string }; _reqId?: string; }
|
||||
/** Client → broker: stop and remove a managed MCP server. */
|
||||
export interface WSMcpUndeployMessage { type: "mcp_undeploy"; server_name: string; _reqId?: string; }
|
||||
/** Client → broker: pull + rebuild + restart a git-sourced MCP. */
|
||||
export interface WSMcpUpdateMessage { type: "mcp_update"; server_name: string; _reqId?: string; }
|
||||
/** Client → broker: get logs from a managed MCP. */
|
||||
export interface WSMcpLogsMessage { type: "mcp_logs"; server_name: string; lines?: number; _reqId?: string; }
|
||||
/** Client → broker: get or set visibility scope. */
|
||||
export interface WSMcpScopeMessage { type: "mcp_scope"; server_name: string; scope?: "peer" | "mesh" | { peers: string[] } | { group: string } | { groups: string[] } | { role: string }; _reqId?: string; }
|
||||
/** Client → broker: inspect tool schemas for a deployed service. */
|
||||
export interface WSMcpSchemaMessage { type: "mcp_schema"; server_name: string; tool_name?: string; _reqId?: string; }
|
||||
/** Client → broker: list all deployed services. */
|
||||
export interface WSMcpCatalogMessage { type: "mcp_catalog"; _reqId?: string; }
|
||||
/** Client → broker: deploy a skill bundle from zip or git. */
|
||||
export interface WSSkillDeployMessage { type: "skill_deploy"; source: { type: "zip"; file_id: string } | { type: "git"; url: string; branch?: string; auth?: string }; _reqId?: string; }
|
||||
/** Client → broker: store encrypted credential. */
|
||||
export interface WSVaultSetMessage { type: "vault_set"; key: string; ciphertext: string; nonce: string; sealed_key: string; entry_type: "env" | "file"; mount_path?: string; description?: string; _reqId?: string; }
|
||||
/** Client → broker: list vault entries. */
|
||||
export interface WSVaultListMessage { type: "vault_list"; _reqId?: string; }
|
||||
/** Client → broker: delete vault entry. */
|
||||
export interface WSVaultDeleteMessage { type: "vault_delete"; key: string; _reqId?: string; }
|
||||
/** Client → broker: fetch encrypted vault entries for local decryption. */
|
||||
export interface WSVaultGetMessage { type: "vault_get"; keys: string[]; _reqId?: string; }
|
||||
|
||||
/** Client → broker: start watching a URL for changes. */
|
||||
export interface WSWatchMessage { type: "watch"; url: string; mode?: "hash" | "json" | "status"; extract?: string; interval?: number; notify_on?: string; headers?: Record<string, string>; label?: string; _reqId?: string; }
|
||||
/** Client → broker: stop watching. */
|
||||
export interface WSUnwatchMessage { type: "unwatch"; watchId: string; _reqId?: string; }
|
||||
/** Client → broker: list active watches. */
|
||||
export interface WSWatchListMessage { type: "watch_list"; _reqId?: string; }
|
||||
/** Broker → client: watch created acknowledgement. */
|
||||
export interface WSWatchAckMessage { type: "watch_ack"; watchId: string; url: string; mode: string; interval: number; _reqId?: string; }
|
||||
/** Broker → client: watch list response. */
|
||||
export interface WSWatchListResultMessage { type: "watch_list_result"; watches: Array<{ id: string; url: string; mode: string; label?: string; interval: number; lastHash?: string; lastValue?: string; lastCheck?: string; createdAt: string }>; _reqId?: string; }
|
||||
/** Broker → client: URL change detected. */
|
||||
export interface WSWatchTriggeredMessage { type: "watch_triggered"; watchId: string; url: string; label?: string; mode: string; oldValue: string; newValue: string; timestamp: string; }
|
||||
|
||||
export type WSClientMessage =
|
||||
| WSHelloMessage
|
||||
| WSSendMessage
|
||||
| WSSetStatusMessage
|
||||
| WSListPeersMessage
|
||||
| WSSetSummaryMessage
|
||||
| WSSetVisibleMessage
|
||||
| WSSetProfileMessage
|
||||
| WSJoinGroupMessage
|
||||
| WSLeaveGroupMessage
|
||||
| WSSetStateMessage
|
||||
@@ -627,6 +1138,7 @@ export type WSClientMessage =
|
||||
| WSListFilesMessage
|
||||
| WSFileStatusMessage
|
||||
| WSDeleteFileMessage
|
||||
| WSGrantFileAccessMessage
|
||||
| WSShareContextMessage
|
||||
| WSGetContextMessage
|
||||
| WSListContextsMessage
|
||||
@@ -648,7 +1160,135 @@ export type WSClientMessage =
|
||||
| WSSubscribeMessage
|
||||
| WSUnsubscribeMessage
|
||||
| WSListStreamsMessage
|
||||
| WSMeshInfoMessage;
|
||||
| WSMeshInfoMessage
|
||||
| WSSetClockMessage
|
||||
| WSPauseClockMessage
|
||||
| WSResumeClockMessage
|
||||
| WSGetClockMessage
|
||||
| WSScheduleMessage
|
||||
| WSListScheduledMessage
|
||||
| WSCancelScheduledMessage
|
||||
| WSMcpRegisterMessage
|
||||
| WSMcpUnregisterMessage
|
||||
| WSMcpListMessage
|
||||
| WSMcpCallMessage
|
||||
| WSMcpCallResponseMessage
|
||||
| WSShareSkillMessage
|
||||
| WSGetSkillMessage
|
||||
| WSListSkillsMessage
|
||||
| WSRemoveSkillMessage
|
||||
| WSSetStatsMessage
|
||||
| WSCreateWebhookMessage
|
||||
| WSListWebhooksMessage
|
||||
| WSDeleteWebhookMessage
|
||||
| WSPeerFileRequestMessage
|
||||
| WSPeerFileResponseMessage
|
||||
| WSPeerDirRequestMessage
|
||||
| WSPeerDirResponseMessage
|
||||
| WSAuditQueryMessage
|
||||
| WSAuditVerifyMessage
|
||||
| WSMcpDeployMessage
|
||||
| WSMcpUndeployMessage
|
||||
| WSMcpUpdateMessage
|
||||
| WSMcpLogsMessage
|
||||
| WSMcpScopeMessage
|
||||
| WSMcpSchemaMessage
|
||||
| WSMcpCatalogMessage
|
||||
| WSSkillDeployMessage
|
||||
| WSVaultSetMessage
|
||||
| WSVaultListMessage
|
||||
| WSVaultDeleteMessage
|
||||
| WSVaultGetMessage
|
||||
| WSWatchMessage
|
||||
| WSUnwatchMessage
|
||||
| WSWatchListMessage;
|
||||
|
||||
// --- Skill messages ---
|
||||
|
||||
/** Client → broker: publish or update a skill. */
|
||||
export interface WSShareSkillMessage {
|
||||
type: "share_skill";
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
tags?: string[];
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: load a skill by name. */
|
||||
export interface WSGetSkillMessage {
|
||||
type: "get_skill";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list skills, optionally filtered by keyword. */
|
||||
export interface WSListSkillsMessage {
|
||||
type: "list_skills";
|
||||
query?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: remove a skill by name. */
|
||||
export interface WSRemoveSkillMessage {
|
||||
type: "remove_skill";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for share_skill or remove_skill. */
|
||||
export interface WSSkillAckMessage {
|
||||
type: "skill_ack";
|
||||
name: string;
|
||||
action: "shared" | "removed" | "not_found";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to get_skill with full skill data. */
|
||||
export interface WSSkillDataMessage {
|
||||
type: "skill_data";
|
||||
skill: {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_skills. */
|
||||
export interface WSSkillListMessage {
|
||||
type: "skill_list";
|
||||
skills: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: deployment progress/result. */
|
||||
export interface WSMcpDeployStatusMessage { type: "mcp_deploy_status"; server_name: string; status: "building" | "installing" | "running" | "failed"; tools?: Array<{ name: string; description: string; inputSchema: object }>; error?: string; _reqId?: string; }
|
||||
/** Broker → client: service log output. */
|
||||
export interface WSMcpLogsResultMessage { type: "mcp_logs_result"; server_name: string; lines: string[]; _reqId?: string; }
|
||||
/** Broker → client: tool schema introspection result. */
|
||||
export interface WSMcpSchemaResultMessage { type: "mcp_schema_result"; server_name: string; tools: Array<{ name: string; description: string; inputSchema: object }>; _reqId?: string; }
|
||||
/** Broker → client: full service catalog. */
|
||||
export interface WSMcpCatalogResultMessage { type: "mcp_catalog_result"; services: Array<{ name: string; type: "mcp" | "skill"; description: string; status: string; tool_count: number; deployed_by: string; scope: { type: string; [key: string]: unknown }; source_type: string; runtime?: string; created_at: string }>; _reqId?: string; }
|
||||
/** Broker → client: scope query/set result. */
|
||||
export interface WSMcpScopeResultMessage { type: "mcp_scope_result"; server_name: string; scope: { type: string; [key: string]: unknown }; deployed_by: string; _reqId?: string; }
|
||||
/** Broker → client: skill deploy acknowledgement. */
|
||||
export interface WSSkillDeployAckMessage { type: "skill_deploy_ack"; name: string; files: string[]; _reqId?: string; }
|
||||
/** Broker → client: vault operation acknowledgement. */
|
||||
export interface WSVaultAckMessage { type: "vault_ack"; key: string; action: "stored" | "deleted" | "not_found"; _reqId?: string; }
|
||||
/** Broker → client: vault entry listing. */
|
||||
export interface WSVaultListResultMessage { type: "vault_list_result"; entries: Array<{ key: string; entry_type: "env" | "file"; mount_path?: string; description?: string; updated_at: string }>; _reqId?: string; }
|
||||
/** Broker → client: encrypted vault entries for local decryption. */
|
||||
export interface WSVaultGetResultMessage { type: "vault_get_result"; entries: Array<{ key: string; ciphertext: string; nonce: string; sealed_key: string; entry_type: string; mount_path?: string }>; _reqId?: string; }
|
||||
|
||||
export type WSServerMessage =
|
||||
| WSHelloAckMessage
|
||||
@@ -664,11 +1304,13 @@ export type WSServerMessage =
|
||||
| WSFileUrlMessage
|
||||
| WSFileListMessage
|
||||
| WSFileStatusResultMessage
|
||||
| WSGrantFileAccessOkMessage
|
||||
| WSContextSharedMessage
|
||||
| WSContextResultsMessage
|
||||
| WSContextListMessage
|
||||
| WSTaskCreatedMessage
|
||||
| WSTaskListMessage
|
||||
| WSVectorStoredMessage
|
||||
| WSVectorResultsMessage
|
||||
| WSCollectionListMessage
|
||||
| WSGraphResultMessage
|
||||
@@ -676,6 +1318,38 @@ export type WSServerMessage =
|
||||
| WSMeshSchemaResultMessage
|
||||
| WSStreamCreatedMessage
|
||||
| WSStreamDataMessage
|
||||
| WSSubscribedMessage
|
||||
| WSStreamListMessage
|
||||
| WSMeshInfoResultMessage
|
||||
| WSScheduledAckMessage
|
||||
| WSScheduledListMessage
|
||||
| WSCancelScheduledAckMessage
|
||||
| WSMcpRegisterAckMessage
|
||||
| WSMcpListResultMessage
|
||||
| WSMcpCallResultMessage
|
||||
| WSMcpCallForwardMessage
|
||||
| WSClockStatusMessage
|
||||
| WSSkillAckMessage
|
||||
| WSSkillDataMessage
|
||||
| WSSkillListMessage
|
||||
| WSWebhookAckMessage
|
||||
| WSWebhookListMessage
|
||||
| WSPeerFileRequestForwardMessage
|
||||
| WSPeerFileResponseForwardMessage
|
||||
| WSPeerDirRequestForwardMessage
|
||||
| WSPeerDirResponseForwardMessage
|
||||
| WSAuditResultMessage
|
||||
| WSAuditVerifyResultMessage
|
||||
| WSMcpDeployStatusMessage
|
||||
| WSMcpLogsResultMessage
|
||||
| WSMcpSchemaResultMessage
|
||||
| WSMcpCatalogResultMessage
|
||||
| WSMcpScopeResultMessage
|
||||
| WSSkillDeployAckMessage
|
||||
| WSVaultAckMessage
|
||||
| WSVaultListResultMessage
|
||||
| WSVaultGetResultMessage
|
||||
| WSWatchAckMessage
|
||||
| WSWatchListResultMessage
|
||||
| WSWatchTriggeredMessage
|
||||
| WSErrorMessage;
|
||||
|
||||
97
apps/broker/src/webhooks.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Inbound webhook handler.
|
||||
*
|
||||
* External services POST JSON to `/hook/:meshId/:secret`. The broker
|
||||
* verifies the secret against the mesh.webhook table, then pushes the
|
||||
* payload to all connected peers in that mesh as a "webhook" push.
|
||||
*/
|
||||
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { meshWebhook } from "@turbostarter/db/schema/mesh";
|
||||
import type { WSPushMessage } from "./types";
|
||||
import { log } from "./logger";
|
||||
|
||||
export interface WebhookResult {
|
||||
status: number;
|
||||
body: { ok: boolean; delivered?: number; error?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a webhook by meshId + secret, verify it's active, then return
|
||||
* the webhook name for push routing. Returns null if not found/inactive.
|
||||
*/
|
||||
async function findActiveWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
): Promise<{ id: string; name: string; meshId: string } | null> {
|
||||
const rows = await db
|
||||
.select({ id: meshWebhook.id, name: meshWebhook.name, meshId: meshWebhook.meshId })
|
||||
.from(meshWebhook)
|
||||
.where(
|
||||
and(
|
||||
eq(meshWebhook.meshId, meshId),
|
||||
eq(meshWebhook.secret, secret),
|
||||
eq(meshWebhook.active, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an inbound webhook HTTP request.
|
||||
*
|
||||
* @param meshId - mesh ID from the URL path
|
||||
* @param secret - webhook secret from the URL path
|
||||
* @param body - parsed JSON body from the request
|
||||
* @param broadcastToMesh - callback to push a message to all connected peers in a mesh.
|
||||
* Returns the number of peers the message was delivered to.
|
||||
*/
|
||||
export async function handleWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
body: unknown,
|
||||
broadcastToMesh: (meshId: string, msg: WSPushMessage) => number,
|
||||
): Promise<WebhookResult> {
|
||||
try {
|
||||
const webhook = await findActiveWebhook(meshId, secret);
|
||||
if (!webhook) {
|
||||
log.warn("webhook auth failed", { mesh_id: meshId });
|
||||
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
||||
}
|
||||
|
||||
if (body === null || body === undefined || typeof body !== "object") {
|
||||
return { status: 400, body: { ok: false, error: "invalid JSON body" } };
|
||||
}
|
||||
|
||||
const pushMsg: WSPushMessage = {
|
||||
type: "push",
|
||||
subtype: "webhook" as any,
|
||||
event: webhook.name,
|
||||
eventData: body as Record<string, unknown>,
|
||||
messageId: crypto.randomUUID(),
|
||||
meshId: webhook.meshId,
|
||||
senderPubkey: `webhook:${webhook.name}`,
|
||||
priority: "next",
|
||||
nonce: "",
|
||||
ciphertext: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const delivered = broadcastToMesh(webhook.meshId, pushMsg);
|
||||
|
||||
log.info("webhook delivered", {
|
||||
webhook_name: webhook.name,
|
||||
mesh_id: webhook.meshId,
|
||||
delivered,
|
||||
});
|
||||
|
||||
return { status: 200, body: { ok: true, delivered } };
|
||||
} catch (e) {
|
||||
log.error("webhook handler error", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return { status: 500, body: { ok: false, error: "internal error" } };
|
||||
}
|
||||
}
|
||||
268
apps/broker/tests/invite-v2.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* v2 invite protocol — broker claim endpoint.
|
||||
*
|
||||
* Covers the sealed-root-key delivery flow added in
|
||||
* .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md :
|
||||
*
|
||||
* - happy path: signed v2 invite claim returns a sealed root_key the
|
||||
* recipient can unseal back to the mesh.rootKey column value
|
||||
* - tampered signature → 400 bad_signature
|
||||
* - expired invite → 410 expired
|
||||
* - revoked invite → 410 revoked
|
||||
* - exhausted invite (usedCount === maxUses) → 410 exhausted
|
||||
* - round-trip: recipient-side crypto_box_seal_open recovers the real key
|
||||
*
|
||||
* Tests talk directly to claimInviteV2Core() to avoid spinning up the
|
||||
* full broker HTTP server. The handler delegates to this function with
|
||||
* zero extra logic, so coverage is equivalent.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { db } from "../src/db";
|
||||
import { invite, mesh } from "@turbostarter/db/schema/mesh";
|
||||
import { canonicalInviteV2 } from "../src/crypto";
|
||||
import { claimInviteV2Core } from "../src/index";
|
||||
import {
|
||||
cleanupAllTestMeshes,
|
||||
setupTestMesh,
|
||||
type TestMesh,
|
||||
} from "./helpers";
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupAllTestMeshes();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await sodium.ready;
|
||||
});
|
||||
|
||||
/**
|
||||
* Set a random base64url root_key on an existing test mesh. The helpers
|
||||
* don't set one by default, so v2 tests prime it per-mesh here.
|
||||
*/
|
||||
async function primeRootKey(meshId: string): Promise<Uint8Array> {
|
||||
const key = sodium.randombytes_buf(32);
|
||||
const b64 = sodium.to_base64(key, sodium.base64_variants.URLSAFE_NO_PADDING);
|
||||
await db.update(mesh).set({ rootKey: b64 }).where(eq(mesh.id, meshId));
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a signed v2 invite row. Returns the opaque short code + the
|
||||
* recipient x25519 keypair the test will use to unseal.
|
||||
*/
|
||||
async function insertV2Invite(
|
||||
m: TestMesh,
|
||||
opts: {
|
||||
code: string;
|
||||
expiresInSec?: number;
|
||||
maxUses?: number;
|
||||
role?: "admin" | "member";
|
||||
tamper?: boolean; // corrupt the signature
|
||||
revoked?: boolean;
|
||||
used?: number;
|
||||
},
|
||||
): Promise<{ inviteId: string; canonical: string }> {
|
||||
const expiresInSec = opts.expiresInSec ?? 3600;
|
||||
const expiresAt = new Date(Date.now() + expiresInSec * 1000);
|
||||
const maxUses = opts.maxUses ?? 1;
|
||||
const role = opts.role ?? "member";
|
||||
|
||||
// Insert first with a placeholder capability so we have the invite id.
|
||||
const [row] = await db
|
||||
.insert(invite)
|
||||
.values({
|
||||
meshId: m.meshId,
|
||||
token: `v2-test-token-${opts.code}`,
|
||||
code: opts.code,
|
||||
maxUses,
|
||||
usedCount: opts.used ?? 0,
|
||||
role,
|
||||
expiresAt,
|
||||
createdBy: "test-user-integration",
|
||||
version: 2,
|
||||
revokedAt: opts.revoked ? new Date() : null,
|
||||
})
|
||||
.returning({ id: invite.id });
|
||||
if (!row) throw new Error("v2 invite insert failed");
|
||||
|
||||
// Now compute canonical_v2 using the real invite id and sign with the
|
||||
// mesh owner's ed25519 secret key.
|
||||
const expiresAtUnix = Math.floor(expiresAt.getTime() / 1000);
|
||||
const canonical = canonicalInviteV2({
|
||||
mesh_id: m.meshId,
|
||||
invite_id: row.id,
|
||||
expires_at: expiresAtUnix,
|
||||
role,
|
||||
owner_pubkey: m.ownerPubkey,
|
||||
});
|
||||
let signatureHex = sodium.to_hex(
|
||||
sodium.crypto_sign_detached(
|
||||
sodium.from_string(canonical),
|
||||
sodium.from_hex(m.ownerSecretKey),
|
||||
),
|
||||
);
|
||||
if (opts.tamper) {
|
||||
// Flip a single hex nibble — keeps length valid, invalidates signature.
|
||||
const first = signatureHex[0] === "0" ? "1" : "0";
|
||||
signatureHex = first + signatureHex.slice(1);
|
||||
}
|
||||
|
||||
const capability = JSON.stringify({
|
||||
canonical,
|
||||
signature: signatureHex,
|
||||
});
|
||||
await db
|
||||
.update(invite)
|
||||
.set({ capabilityV2: capability })
|
||||
.where(eq(invite.id, row.id));
|
||||
return { inviteId: row.id, canonical };
|
||||
}
|
||||
|
||||
function genRecipientX25519(): { pk: string; sk: Uint8Array } {
|
||||
const kp = sodium.crypto_box_keypair();
|
||||
return {
|
||||
pk: sodium.to_base64(kp.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
||||
sk: kp.privateKey,
|
||||
};
|
||||
}
|
||||
|
||||
describe("claimInviteV2Core — v2 invite claim", () => {
|
||||
let m: TestMesh;
|
||||
afterEach(async () => m && (await m.cleanup()));
|
||||
|
||||
test("happy path: signed v2 invite returns sealed root_key and member row", async () => {
|
||||
m = await setupTestMesh("v2-ok");
|
||||
const rootKeyBytes = await primeRootKey(m.meshId);
|
||||
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||
const { inviteId, canonical } = await insertV2Invite(m, { code });
|
||||
const recipient = genRecipientX25519();
|
||||
|
||||
const result = await claimInviteV2Core({
|
||||
code,
|
||||
recipientX25519PubkeyBase64url: recipient.pk,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.mesh_id).toBe(m.meshId);
|
||||
expect(result.body.owner_pubkey).toBe(m.ownerPubkey);
|
||||
expect(result.body.canonical_v2).toBe(canonical);
|
||||
expect(result.body.member_id).toBeTruthy();
|
||||
|
||||
// Recipient unseals the sealed_root_key using its x25519 secret key.
|
||||
const sealed = sodium.from_base64(
|
||||
result.body.sealed_root_key,
|
||||
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
const recipientPkBytes = sodium.from_base64(
|
||||
recipient.pk,
|
||||
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
const opened = sodium.crypto_box_seal_open(
|
||||
sealed,
|
||||
recipientPkBytes,
|
||||
recipient.sk,
|
||||
);
|
||||
expect(opened).toBeInstanceOf(Uint8Array);
|
||||
expect(opened.length).toBe(32);
|
||||
expect(Array.from(opened)).toEqual(Array.from(rootKeyBytes));
|
||||
|
||||
// usedCount incremented and claimedByPubkey recorded.
|
||||
const [updated] = await db
|
||||
.select({
|
||||
usedCount: invite.usedCount,
|
||||
claimedByPubkey: invite.claimedByPubkey,
|
||||
})
|
||||
.from(invite)
|
||||
.where(eq(invite.id, inviteId));
|
||||
expect(updated?.usedCount).toBe(1);
|
||||
expect(updated?.claimedByPubkey).toBe(recipient.pk);
|
||||
});
|
||||
|
||||
test("tampered signature → 400 bad_signature", async () => {
|
||||
m = await setupTestMesh("v2-tampered");
|
||||
await primeRootKey(m.meshId);
|
||||
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||
await insertV2Invite(m, { code, tamper: true });
|
||||
const recipient = genRecipientX25519();
|
||||
|
||||
const result = await claimInviteV2Core({
|
||||
code,
|
||||
recipientX25519PubkeyBase64url: recipient.pk,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.error).toBe("bad_signature");
|
||||
});
|
||||
|
||||
test("expired invite → 410 expired", async () => {
|
||||
m = await setupTestMesh("v2-expired");
|
||||
await primeRootKey(m.meshId);
|
||||
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||
await insertV2Invite(m, { code, expiresInSec: -60 });
|
||||
const recipient = genRecipientX25519();
|
||||
|
||||
const result = await claimInviteV2Core({
|
||||
code,
|
||||
recipientX25519PubkeyBase64url: recipient.pk,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.status).toBe(410);
|
||||
expect(result.body.error).toBe("expired");
|
||||
});
|
||||
|
||||
test("revoked invite → 410 revoked", async () => {
|
||||
m = await setupTestMesh("v2-revoked");
|
||||
await primeRootKey(m.meshId);
|
||||
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||
await insertV2Invite(m, { code, revoked: true });
|
||||
const recipient = genRecipientX25519();
|
||||
|
||||
const result = await claimInviteV2Core({
|
||||
code,
|
||||
recipientX25519PubkeyBase64url: recipient.pk,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.status).toBe(410);
|
||||
expect(result.body.error).toBe("revoked");
|
||||
});
|
||||
|
||||
test("exhausted invite (usedCount >= maxUses) → 410 exhausted", async () => {
|
||||
m = await setupTestMesh("v2-exhausted");
|
||||
await primeRootKey(m.meshId);
|
||||
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||
await insertV2Invite(m, { code, maxUses: 1, used: 1 });
|
||||
const recipient = genRecipientX25519();
|
||||
|
||||
const result = await claimInviteV2Core({
|
||||
code,
|
||||
recipientX25519PubkeyBase64url: recipient.pk,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.status).toBe(410);
|
||||
expect(result.body.error).toBe("exhausted");
|
||||
});
|
||||
|
||||
test("unknown code → 404 not_found", async () => {
|
||||
m = await setupTestMesh("v2-404");
|
||||
await primeRootKey(m.meshId);
|
||||
const recipient = genRecipientX25519();
|
||||
|
||||
const result = await claimInviteV2Core({
|
||||
code: "nonexistent",
|
||||
recipientX25519PubkeyBase64url: recipient.pk,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.body.error).toBe("not_found");
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,9 @@
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
},
|
||||
"types": ["bun-types"]
|
||||
"types": ["bun-types"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
5
apps/cli/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.cache/
|
||||
*.log
|
||||
44
apps/cli/CHANGELOG.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0-alpha.0 (2026-04-13)
|
||||
|
||||
### Architecture
|
||||
- Complete folder restructure: `entrypoints/`, `cli/`, `commands/`, `services/` (17 feature-folders with facade pattern), `ui/`, `mcp/`, `constants/`, `types/`, `utils/`, `locales/`, `templates/`
|
||||
- 212 source files, 10,900 lines
|
||||
- ESM-only, Bun bundler, TypeScript strict mode
|
||||
|
||||
### New CLI commands
|
||||
- `claudemesh register` — account creation via browser handoff
|
||||
- `claudemesh login` — device-code OAuth
|
||||
- `claudemesh logout` — revoke session + clear credentials
|
||||
- `claudemesh whoami` — identity check with `--json` support
|
||||
- `claudemesh new <name>` — create mesh from CLI (was dashboard-only)
|
||||
- `claudemesh invite [email]` — generate invite from CLI (was dashboard-only)
|
||||
|
||||
### Ported from v1 (full feature parity)
|
||||
- All 79 MCP tools
|
||||
- All 85 WS message types (broker protocol unchanged)
|
||||
- Welcome wizard, launch flow, install/uninstall
|
||||
- Ed25519 + NaCl crypto (keypairs, crypto_box DMs, file encryption)
|
||||
- Reconnect with exponential backoff
|
||||
- Status priority engine, scheduled messages, URL watch
|
||||
- Doctor checks, Telegram bridge connect wizard
|
||||
|
||||
### Security hardening (25 bugs fixed across 4 reviews)
|
||||
- `execFile` instead of `exec` for browser open (command injection fix)
|
||||
- ReDoS-safe pattern matching in peer file sharing
|
||||
- Atomic config writes via temp file + rename
|
||||
- Auth token stored with `openSync(mode: 0o600)` — no permission race
|
||||
- Decryption oracle collapsed to generic error in `get_file`
|
||||
- Download size limit (100MB) on file retrieval
|
||||
- Path traversal protection with `realpathSync` for symlink escapes
|
||||
- Callback listener double-resolve guard
|
||||
- Push buffer 1MB per-message truncation
|
||||
- `makeReqId` uses `crypto.randomBytes` instead of `Math.random`
|
||||
- Connect guard prevents double-connect race
|
||||
|
||||
### Breaking changes from v0.10.x
|
||||
- Flat command namespace (no `launch` subcommand, no `advanced` prefix)
|
||||
- New config shape (same data, cleaner layout)
|
||||
- New `--json` output format with `schema_version: "1.0"`
|
||||
- New exit codes (see `constants/exit-codes.ts`)
|
||||
@@ -1,83 +1,90 @@
|
||||
# claudemesh-cli
|
||||
|
||||
Client tool for claudemesh — install once per machine, join one or more
|
||||
meshes, and your Claude Code sessions can talk to peers on demand.
|
||||
Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, and 79 MCP tools.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
# From npm (once published)
|
||||
npm install -g claudemesh-cli
|
||||
|
||||
# Or from the monorepo during dev
|
||||
cd apps/cli && bun link
|
||||
```bash
|
||||
npm i -g claudemesh-cli
|
||||
```
|
||||
|
||||
Then register the MCP server with Claude Code:
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
claudemesh install
|
||||
# prints: claude mcp add claudemesh --scope user -- claudemesh mcp
|
||||
```bash
|
||||
claudemesh register # create account
|
||||
claudemesh new "my-team" # create a mesh
|
||||
claudemesh invite # generate invite link
|
||||
claudemesh # start a session
|
||||
```
|
||||
|
||||
Run the printed command, then restart Claude Code.
|
||||
|
||||
## Join a mesh
|
||||
|
||||
```sh
|
||||
claudemesh join https://claudemesh.com/join/<token>
|
||||
```
|
||||
|
||||
## Launch Claude Code
|
||||
|
||||
For real-time **push messages** from peers (messages injected mid-turn
|
||||
as `<channel source="claudemesh">` system reminders), launch with:
|
||||
|
||||
```sh
|
||||
claudemesh launch
|
||||
# or pass through any claude flags:
|
||||
claudemesh launch --model opus
|
||||
claudemesh launch --resume
|
||||
```
|
||||
|
||||
Under the hood this runs:
|
||||
|
||||
```sh
|
||||
claude --dangerously-load-development-channels server:claudemesh
|
||||
```
|
||||
|
||||
Plain `claude` still works — the MCP tools are available — but incoming
|
||||
messages are **pull-only** via the `check_messages` tool instead of
|
||||
being pushed to Claude immediately.
|
||||
|
||||
The invite link is generated by whoever runs the mesh. It bundles the
|
||||
mesh id, expiry, signing key, and role. Your CLI verifies it,
|
||||
generates a fresh keypair, enrolls you with the broker, and persists
|
||||
the result to `~/.claudemesh/config.json`.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
claudemesh install # register MCP + status hooks
|
||||
claudemesh uninstall # remove MCP + status hooks
|
||||
claudemesh launch [args] # launch Claude Code with push messages enabled
|
||||
claudemesh join <url> # join a mesh via invite URL
|
||||
claudemesh list # show joined meshes + identities
|
||||
claudemesh leave <slug> # leave a mesh
|
||||
claudemesh mcp # start MCP server (stdio — Claude Code only)
|
||||
claudemesh --help # show usage
|
||||
```
|
||||
USAGE
|
||||
claudemesh start a session (creates one if needed)
|
||||
claudemesh <url> join a mesh from an invite link
|
||||
claudemesh new create a new mesh
|
||||
claudemesh invite [email] generate an invite
|
||||
claudemesh list see your meshes
|
||||
claudemesh rename <name> rename the current mesh
|
||||
claudemesh leave [mesh] leave a mesh
|
||||
claudemesh peers see who's online
|
||||
|
||||
claudemesh send <to> <msg> send a message
|
||||
claudemesh inbox drain pending messages
|
||||
claudemesh state ... get, set, or list shared state
|
||||
claudemesh remember <text> store a memory
|
||||
claudemesh recall <query> search memories
|
||||
claudemesh remind ... schedule a reminder
|
||||
claudemesh profile view or edit your profile
|
||||
|
||||
claudemesh doctor diagnose issues
|
||||
claudemesh whoami show current identity
|
||||
claudemesh status check broker connectivity
|
||||
|
||||
claudemesh register create account
|
||||
claudemesh login sign in via browser
|
||||
claudemesh logout sign out
|
||||
|
||||
claudemesh install register MCP server + hooks
|
||||
claudemesh uninstall remove MCP server + hooks
|
||||
```
|
||||
|
||||
## Env overrides
|
||||
## Architecture
|
||||
|
||||
| Var | Default | Purpose |
|
||||
| ----------------------- | ---------------------------- | ------------------------------ |
|
||||
| `CLAUDEMESH_BROKER_URL` | `wss://ic.claudemesh.com/ws` | Point at a self-hosted broker |
|
||||
| `CLAUDEMESH_CONFIG_DIR` | `~/.claudemesh/` | Override config location |
|
||||
| `CLAUDEMESH_DEBUG` | `0` | Verbose logging |
|
||||
```
|
||||
src/
|
||||
├── entrypoints/ CLI + MCP stdio entry points
|
||||
├── cli/ argv parsing, output formatters, signal handling
|
||||
├── commands/ one verb per file (29 commands)
|
||||
├── services/ 17 feature-folders with facade pattern
|
||||
│ ├── auth/ device-code OAuth, token storage
|
||||
│ ├── broker/ WebSocket client (2200 lines), reconnect, crypto
|
||||
│ ├── crypto/ Ed25519, NaCl crypto_box, AES-GCM file encryption
|
||||
│ ├── config/ ~/.claudemesh/config.json with atomic writes
|
||||
│ ├── mesh/ CRUD, join, resolve target
|
||||
│ ├── invite/ generate, parse, claim (v1 + v2 formats)
|
||||
│ ├── api/ typed HTTP client for claudemesh.com
|
||||
│ ├── health/ 6 diagnostic checks
|
||||
│ └── ... device, clipboard, spawn, telemetry, i18n, logger
|
||||
├── mcp/ MCP server with 79 tools across 21 families
|
||||
├── ui/ TUI: styles, spinner, welcome wizard, launch flow
|
||||
├── constants/ exit codes, paths, URLs, timings
|
||||
├── types/ API, mesh, peer interfaces
|
||||
├── utils/ levenshtein, slug, URL, format, semver, retry
|
||||
├── locales/ English strings (i18n ready)
|
||||
└── templates/ 5 mesh templates
|
||||
```
|
||||
|
||||
## Status
|
||||
## Development
|
||||
|
||||
v0.1.0 scaffold — CLI commands + MCP server shell in place. WS broker
|
||||
connection, libsodium crypto, invite-link verification, and auto-install
|
||||
of hooks land in subsequent steps.
|
||||
```bash
|
||||
pnpm install
|
||||
bun run dev # hot-reload
|
||||
bun run build # production build
|
||||
bun run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||