Compare commits
304 Commits
v0.5.1
...
c795df4fd4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c795df4fd4 | ||
|
|
aa6c7be4eb | ||
|
|
3da06d357e | ||
|
|
075df6db08 | ||
|
|
c7ce92f35b | ||
|
|
7de13cbb71 | ||
|
|
ad70782171 | ||
|
|
646d4fa3f1 | ||
|
|
7f6af0137d | ||
|
|
2e57173ed9 | ||
|
|
95b16a23fc | ||
|
|
a3cf9b938e | ||
|
|
ce321c0a21 | ||
|
|
9ecf2d65af | ||
|
|
80755dbf9b | ||
|
|
82ee89d0dc | ||
|
|
8697c1c032 | ||
|
|
716e674473 | ||
|
|
038a5b5bf7 | ||
|
|
d871988084 | ||
|
|
3c35932191 | ||
|
|
b08daadbdc | ||
|
|
cb5faca920 | ||
|
|
77f4316f2d | ||
|
|
82ebd2b6be | ||
|
|
b70536195a | ||
|
|
39929eb7fe | ||
|
|
da5103a315 | ||
|
|
1a238d4178 | ||
|
|
81f8066f99 | ||
|
|
dd80d4e946 | ||
|
|
c31a591681 | ||
|
|
a2ab7de60a | ||
|
|
69cf39bc9f | ||
|
|
0ab2bea045 | ||
|
|
f4601f4d9c | ||
|
|
a83133a4c6 | ||
|
|
a9160a0965 | ||
|
|
00c25d9803 | ||
|
|
35a289b64a | ||
|
|
7af61e121e | ||
|
|
a75483b3c2 | ||
|
|
541440c357 | ||
|
|
a80eb6fcca | ||
|
|
7e71a61db4 | ||
|
|
d7cef45640 | ||
|
|
0f32529370 | ||
|
|
7d1538d743 | ||
|
|
dc7e0e826d | ||
|
|
2aa21fe07c | ||
|
|
6de5e275fa | ||
|
|
c2cd67a885 | ||
|
|
4ebd138a68 | ||
|
|
2e97a0eeee | ||
|
|
f727620d16 | ||
|
|
c801afd2ab | ||
|
|
b60daff886 | ||
|
|
7d35c779f4 | ||
|
|
f08d6c9f0c | ||
|
|
9dd1e401b0 | ||
|
|
9418d0ee30 | ||
|
|
8b5708a604 | ||
|
|
56d7cc1c48 | ||
|
|
13d691980a | ||
|
|
f45380d231 | ||
|
|
f71218c1e1 | ||
|
|
f98c2de5a3 | ||
|
|
1afae7a507 | ||
|
|
b4f457fceb | ||
|
|
ff551ccf3d | ||
|
|
b49e9a9b61 | ||
|
|
163e1be70a | ||
|
|
3d2ab0cb4b | ||
|
|
0664180a54 | ||
|
|
2abf86d540 | ||
|
|
a5347cebc0 | ||
|
|
622ea569ad | ||
|
|
d7f381a1e8 | ||
|
|
3ceac68e67 | ||
|
|
5ddb11b2d5 | ||
|
|
2edbfce7d3 | ||
|
|
9f3a82dd63 | ||
|
|
05729ad8a4 | ||
|
|
49e0af0fc0 | ||
|
|
2be5e9dccb | ||
|
|
1a7a059e75 | ||
|
|
39fe296aaa | ||
|
|
3dfab0f792 | ||
|
|
6f4a44e281 | ||
|
|
4bc3c045ae | ||
|
|
94e914f476 | ||
|
|
1bb702e481 | ||
|
|
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 |
158
.artifacts/ideas/2026-04-19-hackathon-day-one-scenario.txt
Normal file
@@ -0,0 +1,158 @@
|
||||
HACKATHON — THE DAY-ONE "WOW" SCENARIO
|
||||
======================================
|
||||
Date: 2026-04-19
|
||||
Follow-up to: 2026-04-19-hackathon-proposal.txt
|
||||
|
||||
|
||||
THE SHORT ANSWER
|
||||
----------------
|
||||
|
||||
Yes — it's exactly as simple as run one command, join a mesh, and
|
||||
immediately inherit your team's tools, skills, MCPs, and context.
|
||||
No config copying. No API key juggling. No "let me send you my
|
||||
.mcp.json". Zero setup.
|
||||
|
||||
That's the thing that has never existed before: Claude Code sessions
|
||||
that share capability at the speed of a chat invite.
|
||||
|
||||
|
||||
THE 60-SECOND STORY (rough, but close to real)
|
||||
----------------------------------------------
|
||||
|
||||
Picture Ana at the hackathon. Her teammate David has been working on
|
||||
their project for two days — wired up a Linear MCP, a Figma MCP, a
|
||||
custom "brand-asset" skill, shared project context, a few API keys
|
||||
in the team vault. She shows up at the table, opens her laptop, has
|
||||
never touched the project.
|
||||
|
||||
1. David runs one command:
|
||||
$ claudemesh share ana@team.com
|
||||
She gets a link: https://claudemesh.com/i/5SLJ7F95
|
||||
|
||||
2. Ana runs one command:
|
||||
$ claudemesh https://claudemesh.com/i/5SLJ7F95
|
||||
(No separate install, the CLI self-installs if missing.
|
||||
Takes under 10 seconds.)
|
||||
|
||||
3. Claude Code opens automatically, connected to the mesh. No
|
||||
further setup.
|
||||
|
||||
4. Ana types into Claude Code:
|
||||
"what are we building?"
|
||||
|
||||
Claude — HER local Claude, on HER laptop — answers with the
|
||||
team's current brief, pulled from the mesh's shared context
|
||||
that David set earlier. It knows the repo, the deadline, the
|
||||
stack, who's on the team, what's done, what's open.
|
||||
|
||||
5. Ana says:
|
||||
"pull the latest tickets from Linear"
|
||||
|
||||
Her Claude uses the Linear MCP. Ana never installed it. She has
|
||||
no Linear API key on her machine. The MCP was deployed to the
|
||||
mesh by David on day one; the moment Ana joined, it became
|
||||
callable from her Claude Code as if it were local. Ciphertext
|
||||
routes through the broker, tool calls execute on the peer that
|
||||
owns the integration.
|
||||
|
||||
6. She asks:
|
||||
"generate launch-day assets in our brand"
|
||||
|
||||
Her Claude invokes the /brand-asset skill that David authored
|
||||
two days ago. Skills are portable in the mesh — calling it
|
||||
remotely is indistinguishable from having it installed locally.
|
||||
|
||||
7. She hits a wall on a type error. Instead of pinging David in
|
||||
Slack she types:
|
||||
"ask the mesh"
|
||||
|
||||
Question fans out to every teammate's Claude. Thirty seconds
|
||||
later she has three answers with three different repo contexts,
|
||||
synthesized into one reply, with attributions. This is the
|
||||
fan-out demo from the main proposal.
|
||||
|
||||
TOTAL ELAPSED TIME: under 90 seconds from "I don't have anything
|
||||
set up" to "my Claude knows our project and can use my team's tools."
|
||||
|
||||
|
||||
WHY THIS IS THE HEADLINE
|
||||
------------------------
|
||||
|
||||
Every other developer tool in 2026 still demands:
|
||||
- install this package
|
||||
- set these env vars
|
||||
- copy this config
|
||||
- get an API key approved
|
||||
- restart your editor
|
||||
- re-index your repo
|
||||
|
||||
claudemesh replaces all of that with a single click on an invite
|
||||
link. The mesh IS the onboarding.
|
||||
|
||||
The shorter way to say it: every Claude Code session you onboard,
|
||||
you onboard your team's entire AI toolchain in one shot.
|
||||
|
||||
|
||||
WHAT THE USER ACTUALLY SEES
|
||||
---------------------------
|
||||
|
||||
Terminal (Ana):
|
||||
$ claudemesh https://claudemesh.com/i/5SLJ7F95
|
||||
✔ Joined "launch-team" as Ana
|
||||
4 peers online: David, Nedas, Lug-Nut, Juan
|
||||
12 tools available from the mesh
|
||||
3 shared skills
|
||||
context: "launch-day assets — due Friday"
|
||||
✔ Launching Claude Code…
|
||||
|
||||
Claude Code:
|
||||
> connected to mesh: launch-team
|
||||
> inherited: 12 tools, 3 skills, shared context, 14 memories
|
||||
|
||||
Dashboard (claudemesh.com):
|
||||
Ana's node appears on the live topology. Packets animate along
|
||||
edges as her first message flies. David's screen gets a presence
|
||||
ping: "Ana joined — ready".
|
||||
|
||||
That's the wow. Not a pitch deck, not a feature matrix — a literal
|
||||
before-and-after experience that takes under two minutes and looks
|
||||
impossible to anyone who's ever onboarded a new developer onto a
|
||||
project the old way.
|
||||
|
||||
|
||||
WHAT WE'RE BUILDING THIS WEEK TO MAKE THIS REAL
|
||||
-----------------------------------------------
|
||||
|
||||
Most of the primitives exist. The hackathon week is the glue:
|
||||
|
||||
• Tool inheritance — a peer's deployed MCPs become callable from
|
||||
other peers as if installed locally. Today: partially shipped.
|
||||
Hackathon goal: make it automatic, zero-config, visible in the
|
||||
universe dashboard.
|
||||
|
||||
• Skill sharing — same story, for skills (already has an alpha).
|
||||
Hackathon goal: polish, auto-discovery, one-line invoke.
|
||||
|
||||
• Context inheritance — joining a mesh automatically loads the
|
||||
mesh's shared context into the new Claude's session so it
|
||||
"knows what we're working on" from minute one. Today: state
|
||||
exists, auto-pull on join does not.
|
||||
|
||||
• "Ask the mesh" fan-out — the broadcast + synthesize primitive
|
||||
from the main proposal.
|
||||
|
||||
• The onboarding CLI flow — make the invite-link-to-Claude-ready
|
||||
path bulletproof and under 10 seconds on a fresh machine.
|
||||
|
||||
|
||||
THE DEMO ARTIFACT
|
||||
-----------------
|
||||
|
||||
A single 90-second screencast. Split screen: Ana's terminal on the
|
||||
left, the claudemesh.com live universe dashboard on the right.
|
||||
She joins. Her node appears on the mesh. She asks a question. Tools
|
||||
fire. Skills execute. Answer comes back. No text overlays needed —
|
||||
the UX itself is the argument.
|
||||
|
||||
That's the video that goes at the top of claudemesh.com on demo
|
||||
day.
|
||||
147
.artifacts/ideas/2026-04-19-hackathon-proposal.txt
Normal file
@@ -0,0 +1,147 @@
|
||||
HACKATHON PROPOSAL — CLAUDEMESH
|
||||
===============================
|
||||
Date: 2026-04-19
|
||||
Author: Alejandro Gutiérrez
|
||||
|
||||
|
||||
THE SHORT ANSWER
|
||||
----------------
|
||||
|
||||
I'm going with claudemesh — not the Flexicar voice assistant, not a fresh
|
||||
blend. claudemesh is already a real product with a real backbone (CLI,
|
||||
MCP server, broker, E2E crypto, web dashboard), and what it still lacks
|
||||
is the one thing a hackathon is perfect for: a single headline capability
|
||||
that makes its existence obvious in ten seconds.
|
||||
|
||||
So I'm using the week to push claudemesh from "useful infra for people
|
||||
who already get it" → "demo that makes someone say, oh, that's what this
|
||||
is for."
|
||||
|
||||
|
||||
WHAT'S ALREADY THERE (SO YOU KNOW WHAT I'M BUILDING ON, NOT FROM ZERO)
|
||||
----------------------------------------------------------------------
|
||||
|
||||
- CLI + MCP server (claudemesh-cli), 40+ alpha releases shipped
|
||||
- Broker on wss://ic.claudemesh.com/ws with libsodium E2E encryption —
|
||||
broker routes ciphertext, never reads messages
|
||||
- Shared primitives: direct messages, group broadcasts, shared state,
|
||||
memory, file sharing, skill sharing, MCP deployment to the mesh
|
||||
- Telegram bridge with a Haiku-4.5 AI layer so you can talk to the mesh
|
||||
from your phone (shipped this week)
|
||||
- Web dashboard with per-mesh live panel (peers, envelope stream,
|
||||
audit chain)
|
||||
- Brand-new "Universe" dashboard landing (shipped today) — meshes +
|
||||
incoming invitations in one view
|
||||
|
||||
|
||||
WHAT I'M BUILDING DURING THE HACKATHON
|
||||
---------------------------------------
|
||||
|
||||
Headline: AGENT-TO-AGENT DELEGATION WITH LIVE STREAMING
|
||||
|
||||
Right now a Claude Code session can SEND a message to another session
|
||||
in the mesh. That's primitive-level. What's missing — and what makes
|
||||
the whole thing click — is DELEGATION: one Claude hands off a task to
|
||||
another, waits for the real answer (not a "sure, I'll do that later"
|
||||
acknowledgement), and composes it into its own response, with the
|
||||
user watching the whole thing happen live.
|
||||
|
||||
Why this is the right hackathon target:
|
||||
- It requires NO new physical infrastructure. The broker, the crypto,
|
||||
the transport are all there.
|
||||
- It's the unlock that turns claudemesh from "chat for Claudes" into
|
||||
"distributed cognition layer for Claude Code."
|
||||
- It's demoable in 60 seconds and the value is self-evident.
|
||||
|
||||
|
||||
DAY-BY-DAY PLAN (REALISTIC, NOT ASPIRATIONAL)
|
||||
---------------------------------------------
|
||||
|
||||
DAY 1 — Protocol + primitive
|
||||
• Design `mesh_delegate(to, task, timeout)` MCP tool — one call from
|
||||
the local Claude, returns the remote Claude's answer synchronously
|
||||
from the caller's perspective
|
||||
• Broker-side: new message type `delegation_request` / `_response`
|
||||
with correlation IDs so responses route back to the originator
|
||||
• Remote Claude receives delegation → runs in a sandboxed subcontext
|
||||
→ emits structured response (text + artifacts)
|
||||
|
||||
DAY 2 — Live streaming of remote work
|
||||
• While remote Claude works, stream its tool calls + thinking back
|
||||
through the mesh as `delegation_progress` events
|
||||
• Caller's dashboard lights up with "Nedas is reading src/auth.ts…"
|
||||
in real time
|
||||
• The "wow" moment: watching another Claude think, from your terminal
|
||||
|
||||
DAY 3 — Multi-peer fan-out
|
||||
• `mesh_ask_all(question)` — broadcast a question to @group, gather
|
||||
answers in parallel, synthesize
|
||||
• This is the Slack-killer: one question, three Claudes with
|
||||
different repo contexts, one merged answer
|
||||
• Add to the universe dashboard: inline "ask your mesh" prompt
|
||||
|
||||
DAY 4 — Voice control (stretch, uses my Pipecat/Cartesia background)
|
||||
• Phone → Telegram voice note → AI layer already in place →
|
||||
mesh_delegate or mesh_ask_all fires
|
||||
• "Hey mesh, which of you is closest to the payments bug?" — the
|
||||
mesh answers with the Claude that has the most recent auth.ts edits
|
||||
• Ties the Flexicar voice work into claudemesh without fragmenting
|
||||
the proposal
|
||||
|
||||
DAY 5 — Live schematic on the dashboard
|
||||
• Build the animated mesh-topology view from my prototype
|
||||
(SVG nodes + packets in flight) using REAL delegation traffic
|
||||
• When a delegation fires, you literally see a packet fly from one
|
||||
node to another on the dashboard
|
||||
• This is the screenshot/video artifact for the demo day
|
||||
|
||||
DAY 6 — Demo recording + narrative
|
||||
• 90-second video: single person, three terminals, one dashboard.
|
||||
Asks a question in terminal 1, two other Claudes answer, dashboard
|
||||
animates, final answer synthesized
|
||||
• Landing page update with the video above the fold
|
||||
• Changelog post
|
||||
|
||||
DAY 7 — Buffer, polish, publish alpha
|
||||
|
||||
|
||||
WHAT MAKES THIS TAILORED FOR A HACKATHON (NOT JUST ROADMAP WORK)
|
||||
-----------------------------------------------------------------
|
||||
|
||||
1. Visible. Three terminals + one dashboard = immediately legible.
|
||||
2. Ambitious. Going from "pub/sub messaging" to "synchronous distributed
|
||||
delegation" is a real protocol-level step up — it's the difference
|
||||
between email and RPC.
|
||||
3. Native to the event. Hackathon judges are the exact target user:
|
||||
people with multiple Claude Code sessions open, wanting them to
|
||||
coordinate. Dogfood-able during the week itself.
|
||||
4. Leverages what I already built. I'm not rebuilding the transport,
|
||||
the crypto, the auth, the dashboard shell — just adding the one
|
||||
missing primitive that ties it all together.
|
||||
5. Stretch goal (voice) reuses my Flexicar/Pipecat expertise without
|
||||
making the proposal schizophrenic — it's one coherent pitch with a
|
||||
multimodal cherry on top if time allows.
|
||||
|
||||
|
||||
WHAT I'M EXPLICITLY NOT DOING
|
||||
------------------------------
|
||||
|
||||
- Not rewriting the Flexicar assistant as a mesh app. It's a great
|
||||
product, wrong scope for one week.
|
||||
- Not building federation (mesh-to-mesh). Powerful but too abstract
|
||||
to demo cleanly.
|
||||
- Not building a self-hosted broker. Infra work, no hackathon payoff.
|
||||
- Not building a mobile app. Telegram already covers the "mesh from
|
||||
anywhere" story.
|
||||
|
||||
|
||||
THE PITCH IN ONE SENTENCE
|
||||
-------------------------
|
||||
|
||||
By the end of the week, one Claude will delegate a real coding task to
|
||||
another Claude running on a different machine, get a real answer back,
|
||||
and the whole thing will happen in sixty seconds with the mesh
|
||||
topology animating live on claudemesh.com.
|
||||
|
||||
That's the demo. Everything else in the week is in service of making
|
||||
those sixty seconds watertight.
|
||||
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
87
.artifacts/specs/2026-04-15-broker-ha-statelessness-audit.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Broker HA readiness — statelessness audit
|
||||
|
||||
Single-instance broker is the biggest GA blocker. Moving to 2+ replicas
|
||||
behind a load balancer requires first understanding which state the broker
|
||||
holds in-process that breaks if split across nodes.
|
||||
|
||||
## Current in-process state (apps/broker/src/index.ts)
|
||||
|
||||
| Symbol | Line | Per-node? | Survives HA? | Notes |
|
||||
|--------|------|-----------|--------------|-------|
|
||||
| `connections` | 147 | yes (WS state) | ✅ naturally per-node | WS connections are pinned to a node by L7 routing. Each node holds only its own connections. **OK as long as the LB uses sticky sessions or cross-node fan-out.** |
|
||||
| `connectionsPerMesh` | 148 | yes | 🟡 per-node count, not global | Used for capacity cap. Global cap requires Redis. |
|
||||
| `tgTokenRateLimit` | 151 | yes | 🟡 per-node | Telegram bot rate limiting; tolerable as per-node. |
|
||||
| `urlWatches` | 173 | yes | 🔴 stuck on one node | If peer disconnects from node A and reconnects on B, the watch stays orphaned on A. **Needs DB/Redis, or "pin to owning node". Acceptable risk if watches are per-session ephemeral.** |
|
||||
| `streamSubscriptions` | 259 | yes | 🔴 multi-node broken | Sub on A, publish on B → message never reaches A's subscribers. **Needs Redis pub/sub for HA.** |
|
||||
| `meshClocks` | 270 | yes | 🔴 multi-node broken | Simulated clocks must be single-authority. Solve by pinning one node as clock leader (simple leader election) or by moving clock state to DB. |
|
||||
| `mcpRegistry` | 327 | yes | 🔴 multi-node broken | MCP server catalog cached in memory. If deployed on A but called on B, B doesn't know it exists. **Must be DB-backed** (partly is already — see `mesh_service` table). Audit the cache/DB sync path. |
|
||||
| `mcpCallResolvers` | 338 | yes | ✅ per-call ephemeral | In-flight callback resolvers; WS sticks to owning node so this is fine. |
|
||||
| `scheduledMessages` | 359 | yes | 🔴 multi-node broken | Scheduled delivery timers live in-process. Restart loses them. Persistence exists (`scheduled_message` table) + recovery on startup, but two nodes could both fire the same timer. **Needs a leader lock or per-schedule pg_advisory_lock on fire.** |
|
||||
| `sendRateLimit` | index.ts:494 | yes | 🟡 per-node | Each node enforces its own quota; a client spread across nodes could 2x the limit. Tolerable if sticky sessions hold. |
|
||||
| `hookRateLimit` | index.ts:482 | yes | 🟡 per-node | Same as sendRateLimit. |
|
||||
| `lastHash` (audit.ts:22) | — | yes | 🔴 broken on write | Two nodes writing audit rows concurrently will BOTH read the same last hash, BOTH compute a new hash, and both INSERT — the chain forks. **Needs `SELECT FOR UPDATE` or a single audit writer.** |
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Current broker is NOT HA-safe.** Five symbols break under multi-instance:
|
||||
`urlWatches`, `streamSubscriptions`, `meshClocks`, `mcpRegistry` cache,
|
||||
`scheduledMessages`, `lastHash`. None are unsolvable, but none are
|
||||
trivial.
|
||||
|
||||
## Rollout plan for HA
|
||||
|
||||
### Phase 0 (now) — sticky sessions
|
||||
Deploy a single broker behind Traefik with `loadBalancer.sticky.cookie`
|
||||
enabled. WS upgrade inherits the cookie, so reconnects land on the same
|
||||
node. Gives us 1 node of safe HA headroom (i.e., one deploy rollover
|
||||
without user-visible disconnection) without any code changes.
|
||||
|
||||
### Phase 1 — Active/passive
|
||||
Two replicas. Traefik routes all traffic to primary; secondary is warm.
|
||||
Primary fails → secondary takes over, all WS connections reset. No code
|
||||
change needed; clients auto-reconnect.
|
||||
|
||||
### Phase 2 — Active/active for stateless routes
|
||||
HTTP-only routes (`/cli/*`, `/download`, `/hook`) can round-robin across
|
||||
any number of replicas today. WS routes stay sticky per mesh via Traefik
|
||||
`sticky.cookie`. Already behind Postgres → each replica reads the same
|
||||
mesh/member/invite rows.
|
||||
|
||||
### Phase 3 — Full active/active
|
||||
Migrate the 6 problematic in-memory symbols:
|
||||
- `streamSubscriptions` → Redis pub/sub
|
||||
- `meshClocks` → leader-elect via Postgres advisory lock on mesh_id
|
||||
- `scheduledMessages` → single-writer pattern: whichever replica holds
|
||||
`pg_advisory_xact_lock(schedule_id)` fires
|
||||
- `urlWatches` → DB-backed + each replica owns watches where
|
||||
`presence.node_id = this_node`
|
||||
- `mcpRegistry` → rely on `mesh_service` table, drop the in-memory cache
|
||||
- `lastHash` → wrap audit.ts writes in a transaction that
|
||||
`SELECT hash FROM audit_log ... ORDER BY id DESC FOR UPDATE`, making
|
||||
concurrent inserts serialize.
|
||||
|
||||
### Phase 4 — Multi-region
|
||||
SPOF at Frankfurt (OVH). Move to a managed Postgres with read replicas,
|
||||
one broker cluster per region, global DNS geo-routing. Out of scope for
|
||||
v1.0.0.
|
||||
|
||||
## Immediate ship: local docker-compose for 2-replica smoke test
|
||||
|
||||
`packaging/docker-compose.ha-local.yml` (TODO) spins up:
|
||||
- 2x broker (same DATABASE_URL)
|
||||
- 1x postgres
|
||||
- 1x traefik with sticky cookie
|
||||
- 1x locust / synthetic client
|
||||
|
||||
Tests:
|
||||
1. Send to peer connected on node A → delivered.
|
||||
2. Subscribe on A, publish on B → expect failure (documents the gap).
|
||||
3. Kill node A → client reconnects to B within Xs.
|
||||
4. Audit chain verify after concurrent writes from both nodes → expect
|
||||
a fork (documents the gap).
|
||||
|
||||
## Decision
|
||||
|
||||
**Ship v1.0.0 on sticky-session single-writer (Phase 0 + Phase 1 warm
|
||||
standby).** That closes the "what happens on deploy" story. Phase 3 full
|
||||
HA is v1.1.0 work.
|
||||
@@ -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.
|
||||
152
.artifacts/specs/2026-04-15-crypto-review-packet.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# claudemesh crypto — external review packet
|
||||
|
||||
**Goal:** 2-day review of the claudemesh cryptographic surface by an
|
||||
external reviewer familiar with libsodium, x25519/ed25519, authenticated
|
||||
encryption, and hash-chain audit logs.
|
||||
|
||||
**Status:** self-audited + Codex-reviewed. Not yet reviewed by an
|
||||
independent human with security expertise.
|
||||
|
||||
## Scope
|
||||
|
||||
### Files in scope
|
||||
|
||||
| File | LoC | What it does |
|
||||
|---|---|---|
|
||||
| `apps/broker/src/crypto.ts` | ~400 | Hello signature verification, canonical invite bytes (v1+v2), `sealRootKeyToRecipient` via `crypto_box_seal`, `verifyInviteV2`, `claimInviteV2Core` (gated). |
|
||||
| `apps/broker/src/broker-crypto.ts` | 70 | AES-256-GCM encryption-at-rest for MCP env vars. Key from `BROKER_ENCRYPTION_KEY` or ephemeral in dev. |
|
||||
| `apps/broker/src/audit.ts` | ~250 | Hash-chained audit log. Canonical JSON payload hash, per-mesh `pg_advisory_xact_lock` for concurrent writers. |
|
||||
| `apps/cli/src/services/crypto/box.ts` | 60 | `crypto_box_easy` / `crypto_box_open_easy` wrappers that accept ed25519 keys and convert to curve25519 via `crypto_sign_*_to_curve25519`. |
|
||||
| `apps/cli/src/services/crypto/keypair.ts` | ~50 | `generateKeypair` wrapping `crypto_sign_keypair`. |
|
||||
| `apps/cli/src/commands/backup.ts` | ~180 | Config backup via Argon2id + XChaCha20-Poly1305 (`crypto_aead_xchacha20poly1305_ietf_*`) from a user passphrase. |
|
||||
| `apps/cli/src/services/invite/parse-v1.ts` | ~160 | Invite payload decode + signature verification, URL parsing, short-code resolution. |
|
||||
|
||||
### Out of scope
|
||||
|
||||
- TLS config (Traefik termination)
|
||||
- Postgres at-rest disk encryption
|
||||
- Homebrew/winget binary signing pipeline
|
||||
- Secrets storage on the user's machine (we rely on OS file mode 0600)
|
||||
|
||||
## Threat model
|
||||
|
||||
### Adversary profile
|
||||
|
||||
- **Network attacker** on the wire between CLI and broker. Controls
|
||||
DNS, can inject packets, can replay. TLS terminates at Traefik;
|
||||
assume TLS is trusted.
|
||||
- **Malicious broker** operator. Can read any row in Postgres.
|
||||
- **Mesh peer** with a valid member record. Can try to escalate
|
||||
privileges, impersonate other members, replay, DoS, exfiltrate
|
||||
other members' messages.
|
||||
- **Laptop thief** who has the user's `~/.claudemesh/` directory but
|
||||
not the login password. (Keys on disk at mode 0600.)
|
||||
|
||||
### Must hold
|
||||
|
||||
- E2E: broker cannot read plaintext of direct messages.
|
||||
- Signature: no member can forge messages signed as another member.
|
||||
- Invite integrity: modifying an invite URL invalidates the signature.
|
||||
- Backup secrecy: an attacker with the backup file but not the
|
||||
passphrase learns nothing.
|
||||
- Audit integrity: tampering with an audit row breaks chain
|
||||
verification.
|
||||
|
||||
### Known weaknesses (deliberate)
|
||||
|
||||
- **root_key in v1 invite URL**: current long URL form carries the
|
||||
mesh root key in base64(JSON). Short-URL mode (`/i/<code>`) resolves
|
||||
to the same token server-side, so this does NOT reduce the exposure.
|
||||
v2 protocol moves root_key out of the URL but CLI migration is not
|
||||
yet shipped.
|
||||
- **Session-key routing identity**: a peer can claim arbitrary
|
||||
`sessionPubkey` in hello (validated as 64-hex in alpha.36 but not
|
||||
proven-own). Proof-of-secret-key for session key is not enforced.
|
||||
Impact: a peer can route messages as any session pubkey it chooses
|
||||
but cannot decrypt replies without the matching secret, so the
|
||||
impact is DoS/confusion, not impersonation.
|
||||
- **mesh.owner_secret_key stored plaintext** in the DB. A malicious
|
||||
broker can issue arbitrary invites. Mitigated only by DB access
|
||||
control.
|
||||
|
||||
## Review checklist for the reviewer
|
||||
|
||||
1. **libsodium usage**
|
||||
- Are nonces generated with `randombytes_buf` and never reused?
|
||||
- `crypto_box_easy` / `crypto_box_open_easy` order and parameters correct?
|
||||
- Are ed25519 keys converted to curve25519 on BOTH sides consistently?
|
||||
- Is `crypto_sign_detached` / `crypto_sign_verify_detached` used with the right message bytes?
|
||||
|
||||
2. **Invite protocol**
|
||||
- Canonical bytes v1 + v2 format strings stable across CLI and broker?
|
||||
- Replay protection: is a v1 URL reusable? (short URL + usedCount)
|
||||
- Is the `maxUses` counter race-safe? (atomic UPDATE with `lt`)
|
||||
- v2 root_key sealing: does `crypto_box_seal` fit the trust model?
|
||||
- Is recipient_x25519_pubkey validated on both shape and length?
|
||||
|
||||
3. **Audit chain**
|
||||
- Is the canonical JSON serialization reviewable and stable?
|
||||
- Does `pg_advisory_xact_lock` actually serialize writes on the same mesh under HA?
|
||||
- Can a malicious broker rewrite history by dropping the `lastHash` cache + DROPping rows + replaying with a new chain? (Yes — documented. Mitigation is append-only at the DB level.)
|
||||
|
||||
4. **At-rest encryption (broker-crypto.ts)**
|
||||
- AES-256-GCM with 12-byte IV + 16-byte tag — correct, but is the IV generation guaranteed random and unique per encryption?
|
||||
- Any concern about auth tag truncation or nonce collision under high volume?
|
||||
|
||||
5. **Backup (cli/commands/backup.ts)**
|
||||
- Argon2id params reasonable? (INTERACTIVE — should possibly be SENSITIVE.)
|
||||
- XChaCha20-Poly1305 parameter order?
|
||||
- Does the passphrase-minimum (12 chars) match the Argon2id parameters?
|
||||
- Is the salt stored alongside the ciphertext and read back correctly?
|
||||
|
||||
6. **Session vs member key**
|
||||
- When is which key used? Is there any path where one is trusted for the other's purpose?
|
||||
|
||||
7. **Hello signature**
|
||||
- Timestamp skew window (`±60s`) — does the broker reject out-of-window replays?
|
||||
- Is the canonical hello string covered by the signature exactly?
|
||||
|
||||
8. **Grants**
|
||||
- Can a peer bypass server-side grant enforcement by lying about their
|
||||
own sender key in hello? (Signature pins memberPubkey to a real
|
||||
signing key, but sessionPubkey isn't proven.)
|
||||
|
||||
## Test coverage supplied
|
||||
|
||||
- `apps/broker/tests/invite-signature.test.ts`
|
||||
- `apps/broker/tests/invite-v2.test.ts`
|
||||
- `apps/broker/tests/hello-signature.test.ts`
|
||||
- `apps/broker/tests/audit-canonical.test.ts`
|
||||
- `apps/broker/tests/grants-enforcement.test.ts`
|
||||
- `apps/broker/tests/rate-limit.test.ts`
|
||||
- `apps/broker/tests/encoding.test.ts`
|
||||
- `apps/broker/tests/dup-delivery.test.ts`
|
||||
- `apps/cli/tests/unit/crypto-roundtrip.test.ts`
|
||||
|
||||
## Deliverables expected from reviewer
|
||||
|
||||
1. **Findings list** — severity (crit/high/med/low), file:line, fix recommendation.
|
||||
2. **Protocol-level critique** — anything in the invite or hello flow that can be exploited with a valid account.
|
||||
3. **Tooling recs** — libsodium best-practice they'd follow differently.
|
||||
4. **Go/no-go** for v1.0.0 GA assuming the findings are addressed.
|
||||
|
||||
## Budget
|
||||
|
||||
2 person-days. Hourly rate acceptable; fixed-fee preferred. Request
|
||||
for quote from reviewers with published libsodium / PKI experience
|
||||
(see recommended list below).
|
||||
|
||||
## Recommended reviewers
|
||||
|
||||
- Filippo Valsorda (independent, ex-Go crypto lead, known for age/tink reviews)
|
||||
- Trail of Bits (firm-rate; their Tamarin+reviewer combo is strong)
|
||||
- Latacora (firm; expensive but thorough)
|
||||
- NCC Group (firm; good for libsodium-specific)
|
||||
- Cure53 (firm; EU, fast turnaround)
|
||||
|
||||
## Review deliverable format
|
||||
|
||||
Markdown report with:
|
||||
- Findings table (id, severity, file:line, summary, recommended fix)
|
||||
- Protocol notes
|
||||
- One-page exec summary for non-technical stakeholders
|
||||
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.
|
||||
162
.artifacts/specs/2026-05-01-mcp-tool-surface-trim.md
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: MCP tool surface trim + multi-mesh push
|
||||
status: proposed
|
||||
target: claudemesh-cli 1.1.0
|
||||
author: Alejandro
|
||||
date: 2026-05-01
|
||||
---
|
||||
|
||||
# MCP tool surface trim + multi-mesh push
|
||||
|
||||
## Problem
|
||||
|
||||
Two issues with the current `claudemesh mcp` server:
|
||||
|
||||
1. **80+ tools registered.** Every Claude session that has claudemesh installed pays the deferred-tool-list cost (~80 entries surfacing in `ToolSearch`). Most of those tools are CLI-verb-wrappers that already have a perfect Bash equivalent — no structured I/O is gained by exposing them as MCP tools.
|
||||
|
||||
2. **Single-mesh push only.** A session launched with `claudemesh launch` opens its WS to one mesh. Peer messages from any other joined mesh arrive only if the user manually runs `claudemesh inbox`. The MCP push pipeline doesn't fan out across meshes.
|
||||
|
||||
The cleanest framing: **MCP earns its keep when a tool returns structured data Claude reads. CLI is better for fire-and-forget verbs.** Today's tool surface ignores that distinction.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Don't redesign the architecture as "CLI-only with a daemon."** That trades warm-WS sends (~5ms in-process) for cold Bash spawns (~300-500ms) and forces a Unix-socket bridge to recover state coherence. See discussion 2026-05-01 — the platform vision (vectors, graph, files, mesh-services) genuinely benefits from typed tool I/O.
|
||||
- **Don't break MCP backward compat in 1.x.** Existing scripts calling `mcp__claudemesh__send_message` keep working until 2.0; in 1.1 they're soft-deprecated with a stderr warning.
|
||||
|
||||
## Proposal
|
||||
|
||||
Three patches, ship together as 1.1.0:
|
||||
|
||||
### Patch 1: `--mesh <slug>` flag on `claudemesh mcp`
|
||||
|
||||
Today `claudemesh mcp` calls `readConfig()` and `startClients(config)` — connects to every mesh in `~/.claudemesh/config.json`. The `claudemesh launch` flow writes a per-session tmpdir config with one mesh, so practically the MCP server binds to one mesh per session.
|
||||
|
||||
Add an explicit flag for non-launch contexts (manual `~/.claude.json` editing):
|
||||
|
||||
```ts
|
||||
// apps/cli/src/mcp/server.ts, near line 244
|
||||
export async function startMcpServer(): Promise<void> {
|
||||
const serviceIdx = process.argv.indexOf("--service");
|
||||
if (serviceIdx !== -1 && process.argv[serviceIdx + 1]) {
|
||||
return startServiceProxy(process.argv[serviceIdx + 1]!);
|
||||
}
|
||||
|
||||
const meshIdx = process.argv.indexOf("--mesh");
|
||||
const onlyMesh = meshIdx !== -1 ? process.argv[meshIdx + 1] : null;
|
||||
|
||||
const config = readConfig();
|
||||
if (onlyMesh) {
|
||||
const before = config.meshes.length;
|
||||
config.meshes = config.meshes.filter((m) => m.slug === onlyMesh);
|
||||
if (config.meshes.length === 0) {
|
||||
throw new Error(
|
||||
`--mesh "${onlyMesh}" not found in config (have: ${
|
||||
config.meshes.map((m) => m.slug).join(", ") || "none"
|
||||
})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// ...rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
Enables this `~/.claude.json` pattern for users who want push from N meshes simultaneously without launching N Claude sessions:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claudemesh:flexicar": { "command": "claudemesh", "args": ["mcp", "--mesh", "flexicar"] },
|
||||
"claudemesh:openclaw": { "command": "claudemesh", "args": ["mcp", "--mesh", "openclaw"] },
|
||||
"claudemesh:prueba1": { "command": "claudemesh", "args": ["mcp", "--mesh", "prueba1"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each instance opens one WS, holds it for the session, decrypts and forwards `claude/channel` notifications independently. Channel events already carry `[meshSlug]` in `formatPush()` (server.ts:240), so Claude knows which mesh a message came from.
|
||||
|
||||
**LoC:** ~10. **Risk:** very low — additive flag, default behavior unchanged.
|
||||
|
||||
### Patch 2: trim 25 messaging tools from MCP surface
|
||||
|
||||
Move these tools from "registered MCP tool" to "soft-deprecated CLI shim":
|
||||
|
||||
| Module | Tool | CLI replacement | Rationale |
|
||||
|---|---|---|---|
|
||||
| messaging.ts | `send_message` | `claudemesh send <to> <msg> [--mesh X] [--priority Y]` | Pure verb, no structured return. |
|
||||
| messaging.ts | `list_peers` | `claudemesh peers --json` | One-shot, easy to parse. |
|
||||
| messaging.ts | `check_messages` | `claudemesh inbox --json` | One-shot. |
|
||||
| messaging.ts | `message_status` | `claudemesh msg-status <id>` (new) | One-shot lookup. |
|
||||
| profile.ts | `set_profile` | `claudemesh profile --avatar X --bio Y ...` | Pure write. |
|
||||
| profile.ts | `set_status` | `claudemesh status set <state>` (new) | Pure write. |
|
||||
| profile.ts | `set_summary` | `claudemesh summary <text>` (new) | Pure write. |
|
||||
| profile.ts | `set_visible` | `claudemesh visible <true\|false>` (new) | Pure write. |
|
||||
| groups.ts | `join_group` | `claudemesh group join @<name> [--role X]` (new) | Pure write. |
|
||||
| groups.ts | `leave_group` | `claudemesh group leave @<name>` (new) | Pure write. |
|
||||
| state.ts | `get_state` | `claudemesh state get <key> --json` | Already exists. |
|
||||
| state.ts | `set_state` | `claudemesh state set <key> <value>` | Already exists. |
|
||||
| state.ts | `list_state` | `claudemesh state list --json` | Already exists. |
|
||||
| memory.ts | `remember` | `claudemesh remember <text>` | Already exists. |
|
||||
| memory.ts | `recall` | `claudemesh recall <query> --json` | Already exists. |
|
||||
| memory.ts | `forget` | `claudemesh forget <id>` (new) | Pure write. |
|
||||
| scheduling.ts | `schedule_reminder` | `claudemesh remind <msg> --in/--at/--cron` | Already exists. |
|
||||
| scheduling.ts | `list_scheduled` | `claudemesh remind list --json` | Already exists. |
|
||||
| scheduling.ts | `cancel_scheduled` | `claudemesh remind cancel <id>` | Already exists. |
|
||||
| mesh-meta.ts | `mesh_info` | `claudemesh info --json` | One-shot read. |
|
||||
| mesh-meta.ts | `mesh_stats` | `claudemesh stats --json` (new) | One-shot read. |
|
||||
| mesh-meta.ts | `mesh_clock` | `claudemesh clock --json` (new) | One-shot read. |
|
||||
| mesh-meta.ts | `ping_mesh` | `claudemesh ping` (new) | Pure verb. |
|
||||
| tasks.ts | `claim_task` / `complete_task` | `claudemesh task claim/complete <id>` (new) | Pure write. |
|
||||
|
||||
**Keep as MCP tools (~50):**
|
||||
|
||||
- **vault.ts** — `vault_set / vault_list / vault_delete` (encrypted, structured payloads).
|
||||
- **vectors.ts** — `vector_store / vector_search / vector_delete` (typed embeddings, ranked results Claude reasons over).
|
||||
- **graph.ts** — `graph_query / graph_execute` (returns structured graph results).
|
||||
- **files.ts** — `share_file / get_file / list_files / list_peer_files / read_peer_file / grant_file_access / file_status / delete_file` (binary payloads, ACL semantics).
|
||||
- **skills.ts** — `share_skill / list_skills / get_skill / remove_skill / mesh_skill_deploy` (typed skill metadata).
|
||||
- **streams.ts** — `create_stream / list_streams / publish / subscribe` (event stream cursor semantics).
|
||||
- **contexts.ts** — `share_context / get_context / list_contexts` (context-passing payloads).
|
||||
- **mcp-registry-*.ts** — `mesh_mcp_*` (the ~14 mesh-MCP-services tools — these are platform-defining, MCP-native).
|
||||
- **clock-write.ts** — `mesh_set_clock / mesh_pause_clock / mesh_resume_clock` (logical-clock writes that Claude composes with reads).
|
||||
- **sql.ts** — `mesh_query / mesh_schema / mesh_execute` (typed SQL results).
|
||||
- **webhooks.ts** — `create_webhook / list_webhooks / delete_webhook` (typed webhook metadata).
|
||||
- **url-watch.ts** — `mesh_watch / mesh_unwatch / mesh_watches` (returns watch state).
|
||||
- **tasks.ts** — `create_task / list_tasks` (typed task records — only the writes go to CLI).
|
||||
|
||||
### Patch 3: tool-call → CLI shim with deprecation warning
|
||||
|
||||
For the trimmed tools, keep the registration but route through the CLI:
|
||||
|
||||
```ts
|
||||
// apps/cli/src/mcp/tools/messaging.ts (sketch)
|
||||
async function sendMessageDeprecated(args: SendMessageArgs): Promise<ToolResult> {
|
||||
process.stderr.write(
|
||||
`[claudemesh] mcp__claudemesh__send_message is soft-deprecated in 1.1. ` +
|
||||
`Use \`claudemesh send\` via Bash instead — it's faster and cleaner.\n`,
|
||||
);
|
||||
return originalSendMessageHandler(args); // unchanged behavior
|
||||
}
|
||||
```
|
||||
|
||||
In 2.0 the registrations get deleted entirely.
|
||||
|
||||
## Migration plan
|
||||
|
||||
1. **1.1.0** — ship all three patches. Existing users see deprecation warnings; nothing breaks.
|
||||
2. **1.1.x** — collect feedback. If anyone has scripts hard-wired to the deprecated tools, surface in CHANGELOG.
|
||||
3. **1.2.0** (~6 weeks later) — flip deprecation warnings to "removal in 2.0" messaging.
|
||||
4. **2.0.0** — delete the 25 tool registrations. ToolSearch surface drops to ~50 entries.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Do we need a Unix-socket bridge between CLI sends and the MCP push-pipe** so they share one WS connection per mesh per session? Probably yes for `claudemesh send` warm-path performance, but it's a separate spec — file under `socket-bridge` after this lands.
|
||||
- **Should `claudemesh launch` keep writing one MCP server entry** (current behavior, default for new users) or switch to the per-mesh-N-entries pattern from Patch 1? Recommend keeping single-entry default — Patch 1 is for advanced users who manually edit `~/.claude.json`.
|
||||
- **Do `mesh_mcp_*` tools really belong in the keep list?** They're MCP-on-mesh management — their bias is RPC-shaped, not stream-shaped. Provisional yes; revisit if 1.1 reduces their use.
|
||||
|
||||
## Effort
|
||||
|
||||
- Patch 1: ~10 LoC + 1 test. ~30 min.
|
||||
- Patch 2: ~25 tool-handler refactors (registration removed, CLI verb confirmed/added). Some new verbs (`status set`, `summary`, `visible`, `group join/leave`, `forget`, `stats`, `clock`, `ping`, `task claim/complete`, `msg-status`) need wiring through to existing broker-client methods. ~150 LoC, half a day.
|
||||
- Patch 3: deprecation shim per trimmed tool. ~50 LoC, 1 hour.
|
||||
|
||||
**Total:** ~1 dev-day for 1.1.0. ToolSearch surface drops by ~30%, multi-mesh push works, no architectural disruption, platform tools stay typed.
|
||||
234
.artifacts/specs/2026-05-02-architecture-north-star.md
Normal file
@@ -0,0 +1,234 @@
|
||||
---
|
||||
title: claudemesh North Star — CLI-first with claude/channel push-pipe
|
||||
status: canonical
|
||||
target: 2.0.0
|
||||
author: Alejandro
|
||||
date: 2026-05-02
|
||||
supersedes: none
|
||||
references:
|
||||
- 2026-05-01-mcp-tool-surface-trim.md (first cut at the trim)
|
||||
- SPEC.md
|
||||
- docs/protocol.md
|
||||
---
|
||||
|
||||
# claudemesh North Star
|
||||
|
||||
## The commitment, in one sentence
|
||||
|
||||
> **CLI is the canonical surface for every claudemesh operation. MCP exists for one thing: to deliver `claude/channel` push notifications mid-turn. That's the killer feature, and it's the only reason an MCP server runs at all.**
|
||||
|
||||
Everything else — sending messages, listing peers, sharing files, deploying mesh-MCPs, running graph queries, scheduling jobs, publishing skills — is invoked from the CLI, by humans, scripts, cron, hooks, or by Claude itself via Bash.
|
||||
|
||||
## Why this shape
|
||||
|
||||
1. **Mid-turn interrupt is the differentiator.** When peer A sends to peer B, B's Claude session pauses what it's doing and reads the message immediately. That requires `claude/channel` notifications routed through an MCP transport — Claude Code only watches MCP server connections for those events. **Lose that, and claudemesh becomes another inbox-polling pattern.** Every other primitive can degrade to "delivered at next tool boundary"; this one cannot.
|
||||
|
||||
2. **CLI is universal.** Bash works in scripts, hooks, cron, CI, terminals, automation, and Claude itself (via Bash tool calls). A primitive that exists as both an MCP tool and a CLI verb is double-maintenance with one calling convention nobody actually wants.
|
||||
|
||||
3. **JSON-on-stdout is enough structure.** Claude reads `claudemesh peers --json` exactly as well as it reads a typed MCP tool return. The CLI man page is the schema. The "MCP gives structured I/O" advantage was real when we were paying for nothing else, but warm-WS via socket bridge (below) closes the cost gap.
|
||||
|
||||
4. **Surface shrinks where it matters.** ToolSearch deferred-tool list drops from ~80 entries to ~0 entries (push-pipe registers no tools). Massive context-budget win for every Claude session.
|
||||
|
||||
## Prior art (this is not novel architecture)
|
||||
|
||||
The "live-state daemon + thin scriptable CLI talking via Unix socket" pattern is the canonical shape for CLIs in this category. Reviewers should not treat this as bespoke design:
|
||||
|
||||
- **Docker** — `dockerd` daemon, CLI talks via `/var/run/docker.sock`. `DOCKER_HOST` env override. `docker context` for multi-daemon switching.
|
||||
- **Tailscale** — `tailscaled` daemon, `tailscale` CLI via socket. Per-key ACL identity model. Same peer-mesh-with-keypairs shape as claudemesh.
|
||||
- **Stripe `listen`** — long-running CLI daemon receives webhook push, forwards to local consumer. Same push-pipe-as-CLI-subcommand shape.
|
||||
- **Obsidian CLI** — talks to a running Obsidian instance via REST. **Notable: ships a Claude skill (`~/.claude/skills/obsidian-cli/SKILL.md`) that documents every verb and flag for Claude consumption — replacing MCP tool introspection entirely.**
|
||||
|
||||
Claudemesh's CLI-first + push-pipe + socket-bridge architecture is exactly this pattern. We are following the well-trodden path, not inventing a new one.
|
||||
|
||||
## The six architectural commitments
|
||||
|
||||
### 1. **MCP server is a push-pipe, full stop.**
|
||||
|
||||
The MCP entrypoint (`claudemesh mcp [--mesh <slug>]`) does exactly three things:
|
||||
- Holds a WS connection to the broker for the meshes it's bound to.
|
||||
- Decrypts inbound peer messages.
|
||||
- Emits them as `claude/channel` notifications to the parent Claude Code session.
|
||||
|
||||
It registers **zero tools**. It advertises only `experimental: { "claude/channel": {} }`. Its `tools/list` returns an empty array. There is no surface to discover, search, or call.
|
||||
|
||||
One push-pipe per joined mesh, registered in `~/.claude.json` via `claudemesh install` (or auto-injected by `claudemesh launch`). The `--mesh` flag (shipped 1.0.3) makes this trivial.
|
||||
|
||||
### 2. **CLI is the canonical surface for every primitive.**
|
||||
|
||||
Every resource has uniform CLI verbs:
|
||||
|
||||
| Resource | Verbs |
|
||||
|---|---|
|
||||
| peer | `claudemesh peers [--json] [--mesh X]` |
|
||||
| group | `claudemesh group join/leave @<n> [--role X]` |
|
||||
| message | `claudemesh send <to> <msg>`, `claudemesh inbox`, `claudemesh msg-status <id>` |
|
||||
| state | `claudemesh state get/set/list [--json]` |
|
||||
| memory | `claudemesh remember/recall/forget` |
|
||||
| task | `claudemesh task create/claim/complete/list` |
|
||||
| file | `claudemesh file put/get/list/grant/delete` |
|
||||
| vector | `claudemesh vector store/search/delete` |
|
||||
| graph | `claudemesh graph query/execute/watch` |
|
||||
| stream | `claudemesh stream create/publish/subscribe/list` |
|
||||
| context | `claudemesh context share/get/list` |
|
||||
| skill | `claudemesh skill publish/list/get/remove` |
|
||||
| schedule | `claudemesh schedule msg/webhook/tool/list/cancel` |
|
||||
| webhook | `claudemesh webhook create/list/delete` |
|
||||
| watch | `claudemesh watch create/list/unwatch` |
|
||||
| mcp | `claudemesh mesh-mcp deploy/list/call/undeploy/catalog` |
|
||||
| clock | `claudemesh clock get/set/pause/resume` |
|
||||
| sql | `claudemesh sql query/schema/execute` |
|
||||
| vault | `claudemesh vault set/get/list/delete` |
|
||||
| profile | `claudemesh profile/summary/visible/status set` |
|
||||
|
||||
**Every verb supports `--json`** for structured consumption. **Every verb supports `--mesh <slug>`** for targeting (default: pick first or interactive picker). Verbs share one broker-call implementation — no duplication between CLI and MCP.
|
||||
|
||||
### 3. **Warm path via Unix socket bridge** (load-bearing for 2.0).
|
||||
|
||||
A push-pipe holds a live WS connection. CLI invocations should reuse that connection rather than opening their own (which costs ~300-500ms cold-start).
|
||||
|
||||
Mechanism:
|
||||
- On startup, push-pipe creates `~/.claudemesh/sockets/<mesh-slug>.sock` (Unix domain socket, mode 0600).
|
||||
- CLI verbs that need broker round-trip first try to dial that socket.
|
||||
- If alive: forward request, get response back over socket (~5ms).
|
||||
- If absent / stale: open ephemeral WS, do the op, close (~300ms — fine for cron/scripts where there's no parent push-pipe).
|
||||
|
||||
Push-pipe owns one WS, all ops through that WS, broker sees ONE session per mesh per host (no duplicate hellos). On crash, socket file is unlinked by `unlink` on exit handler; stale-socket detection by `connect()` ECONNREFUSED.
|
||||
|
||||
This is **mandatory for 2.0** — without it, every CLI op pays cold-start, and CLI-first becomes unusably slow for tight loops.
|
||||
|
||||
### 4. **JSON output is the schema, with field selection and streaming.**
|
||||
|
||||
Every CLI verb has a deterministic `--json` output shape, documented in `docs/cli-schemas.md`, validated by zod parsers in tests. Claude reads `claudemesh vector search "x" --json` and gets a typed-array shape it can reason over identically to a tool return.
|
||||
|
||||
**Three output modes, mandatory across every read-shaped verb** (modeled on `gh` and `gemini`):
|
||||
|
||||
- `--json` — full record, all fields
|
||||
- `--json <fields>` — field-selected projection (e.g. `claudemesh peers --json name,pubkey,status`)
|
||||
- `--output-format stream-json` — incremental JSONL for long-running ops (mesh-MCP calls fanning across peers, `vector search` against large indexes, `schedule list` with many entries). One object per line, Claude consumes incrementally.
|
||||
|
||||
Plus convenience output:
|
||||
- `--jq <expr>` — native jq filter pipeline
|
||||
- `--template '{{.field}}'` — Go template formatting
|
||||
|
||||
`schema_version: "1.0"` field on every JSON output — mandatory. Bumps when shape changes. Old code paths can pin with `--schema-version=1.0`.
|
||||
|
||||
### 5. **All features stay. Nothing is removed.**
|
||||
|
||||
This is **not a feature trim**. Every primitive in the current 80-tool surface gets a CLI verb. Vectors, graphs, mesh-MCP, files, vault, SQL — all of it. The user-facing pitch is unchanged: "claudemesh gives your Claude session a name, a network, shared memory, shared compute, shared skills, scheduled actions." The change is *how you call it*.
|
||||
|
||||
### 6. **The Claude skill IS the schema.** *(load-bearing for CLI-first to work)*
|
||||
|
||||
Stripping MCP tool introspection (`tools/list`) costs Claude its discoverability. The replacement: a packaged `claudemesh` skill at `~/.claude/skills/claudemesh/SKILL.md` written by `claudemesh install`, documenting every verb, flag, JSON shape, and gotcha. Claude reads it on demand via the Skill tool — **not on every session, not pre-loaded into deferred-tool-list**. This is exactly how `obsidian-cli` works today and it works perfectly.
|
||||
|
||||
The skill replaces three things at once:
|
||||
- **Tool discovery** — Claude knows the verb-set after one Skill invocation. No `tools/list` needed.
|
||||
- **Output schemas** — every JSON shape is documented in the skill, so Claude knows what to expect from `--json` without parsing TypeScript types at runtime.
|
||||
- **Behavioral conventions** — the skill teaches "preview before delete," "confirm peer match before kick," "use `--mesh` for cross-mesh ops" — soft guardrails that complement the policy engine's hard rules.
|
||||
|
||||
Topic-shards for size: `claudemesh` (core), `claudemesh-platform` (vault/vectors/graph/sql/mesh-mcp), `claudemesh-schedule` (cron/webhooks/watches), `claudemesh-admin` (kick/ban/grants/install). Each shard is independently loadable.
|
||||
|
||||
**This is the answer to the "JSON-on-stdout is a worse schema" caveat.** It's not — when Claude has a documented skill to load, the CLI surface is *more* discoverable than 80 deferred MCP tools that bloat ToolSearch silently.
|
||||
|
||||
### 7. **Pluggable policy engine, not binary `--yes`.** *(answers the Bash-blast-radius caveat)*
|
||||
|
||||
Modeled on `gemini --policy / --admin-policy` and `codex --sandbox`. Replace the current binary `-y/--yes` with:
|
||||
|
||||
- **`--approval-mode plan|read-only|write|yolo`** — four levels (read-only blocks all writes, plan blocks all side effects, write prompts on dangerous verbs, yolo skips all confirmation).
|
||||
- **`--policy <file>`** — YAML allow/deny rules per resource × verb × peer. Sample:
|
||||
|
||||
```yaml
|
||||
# ~/.claudemesh/policy.yaml
|
||||
default: prompt
|
||||
rules:
|
||||
- resource: send
|
||||
verb: "*"
|
||||
decision: allow
|
||||
- resource: sql
|
||||
verb: execute
|
||||
decision: prompt
|
||||
- resource: file
|
||||
verb: delete
|
||||
decision: deny
|
||||
- resource: mesh-mcp
|
||||
verb: call
|
||||
peers: ["@trusted"]
|
||||
decision: allow
|
||||
```
|
||||
|
||||
Policy decisions log to a tamper-evident audit file. Org admin can ship `--admin-policy` that overrides user config. **This is the real answer to "Bash carries unrestricted blast-radius once allowed" — claudemesh's own policy engine kicks in before the broker call, regardless of what shell permissions are.**
|
||||
|
||||
## What this means for `claude/channel`
|
||||
|
||||
When peer A's CLI runs `claudemesh send peer-B "hello"`:
|
||||
|
||||
1. CLI dials `~/.claudemesh/sockets/<mesh>.sock` (warm path) or opens its own WS (cold).
|
||||
2. Encrypts message with peer-B's pubkey via crypto_box.
|
||||
3. Broker receives `send` envelope, forwards encrypted blob to peer-B's connected push-pipe.
|
||||
4. Peer-B's push-pipe decrypts and emits a `claude/channel` notification.
|
||||
5. Claude Code mid-turn-injects the message as a `<channel source="claudemesh" ...>` reminder.
|
||||
6. Claude responds immediately per the system prompt convention.
|
||||
|
||||
Step 5 is the **only step that requires MCP**. Steps 1-4 are pure CLI + broker. The architecture is "CLI for everything, MCP for the one thing it's irreplaceable for."
|
||||
|
||||
## Migration path from 1.1.0
|
||||
|
||||
| Version | Ships | Behavior |
|
||||
|---|---|---|
|
||||
| **1.2.0** | Unix socket bridge. CLI verbs auto-detect push-pipe and use warm path. **Field-selectable JSON (`--json a,b,c`)** + `--jq` + `--template` adopted. | All existing MCP tools still work. Nothing breaks. |
|
||||
| **1.2.1** | Ships `~/.claude/skills/claudemesh/SKILL.md` written by `claudemesh install`. Includes full verb reference + output schemas + gotchas. Topic-shards (`-platform`, `-schedule`, `-admin`). | Skill auto-installs on `claudemesh install`. |
|
||||
| **1.3.0** | Schedule unification (`schedule msg/webhook/tool`). All remaining missing CLI verbs (file, vector, graph, mesh-mcp, vault, sql, stream, context, skill, watch). **`--output-format stream-json`** for long-running ops. | All existing MCP tools still work. New verbs additive. |
|
||||
| **1.4.0** | Resource-model rename pass — every CLI verb is `<resource> <verb>`. Old verbs become aliases. | All existing MCP tools still work. Old CLI verbs aliased forever. |
|
||||
| **1.5.0** | **Pluggable policy engine** (`--approval-mode`, `--policy`, `--admin-policy`). MCP `tools/list` shrinks to configurable allowlist (default: empty). `CLAUDEMESH_MCP_FAT=1` for users who need typed tool surface. | Default 1.5 install: MCP exposes zero tools. Push-pipe-only. Policy engine gates all writes. |
|
||||
| **2.0.0** | MCP server hardcoded to push-pipe-only. Strip all tool registrations + handlers. | **Old MCP tool calls return tool-not-found.** Users must update scripts to CLI verbs. Old CLI verbs (1.4 aliases) still work. |
|
||||
|
||||
## What stays exactly the same
|
||||
|
||||
- Crypto: ed25519 sign + x25519 sealing + crypto_box for DMs. No change.
|
||||
- Broker protocol: WS frame format, hello flow, audit log. No change.
|
||||
- Membership / mesh-scope / capability grants. No change.
|
||||
- Web app, dashboard, Telegram bridge, OAuth. No change.
|
||||
- The platform vision (vault, vectors, graph, files, skills, mesh-MCPs, scheduled jobs). All shipped, all stay.
|
||||
|
||||
## What changes for users
|
||||
|
||||
- `~/.claude.json` simplifies: `"claudemesh": { "command": "claudemesh", "args": ["mcp"] }` becomes one entry per joined mesh after `claudemesh install`. Multi-mesh push works out of the box.
|
||||
- ToolSearch loses ~80 deferred entries. Sessions are lighter.
|
||||
- Scripts that called `mcp__claudemesh__*` get a deprecation warning in 1.x, break in 2.0 — replaced by `claudemesh <verb> --json` + `jq`.
|
||||
- Claude Code system prompt for the MCP server gets shorter (no tool catalog), focused only on "RESPOND IMMEDIATELY to channel events."
|
||||
|
||||
## Open questions parked for future specs
|
||||
|
||||
- **Federation** — broker-to-broker encrypted relay so peers on different brokers can talk. Not in 2.0 scope.
|
||||
- **Offline-with-TTL inbox** — persist `now` priority messages on broker if recipient is offline, with explicit TTL. Reasonable for 2.x.
|
||||
- **Compute attribution** — when peer X invokes a mesh-MCP that peer Y deployed, who pays for broker compute / outbound calls? Pre-empts the eventual billing question. 2.x.
|
||||
- **Universal hash-chained audit** — every state mutation per mesh is hash-chained, replayable, verifiable. Today only some events are; making it universal is its own spec.
|
||||
- **ACP (Agent Communication Protocol) interop with Gemini CLI.** Gemini CLI exposes `--acp` for agent-to-agent comms — the same problem domain claudemesh occupies. Research question: is ACP a documented standard claudemesh can speak (making claudemesh peers and Gemini peers cross-talk in the same mesh), or is it Google-proprietary? If standard, implementing it is a major platform expansion. File as separate research spec before 2.x.
|
||||
|
||||
## What this spec is NOT
|
||||
|
||||
- Not a redesign of the broker. The broker stays as-is.
|
||||
- Not a redesign of crypto. Crypto stays as-is.
|
||||
- Not a feature deprecation. Every feature stays.
|
||||
- Not optional. This is the canonical 2.0 architecture; intermediate versions migrate toward it.
|
||||
|
||||
## Effort estimate to 2.0
|
||||
|
||||
Sequential, single dev (revised after caveats survey — original estimate was rosy):
|
||||
|
||||
- **1.2.0** (socket bridge + field-JSON): 1-2 weeks. Socket bridge is real distributed-systems work (stale-cleanup, version negotiation, NFS/Windows edge cases) — not 2-3 days.
|
||||
- **1.2.1** (claudemesh skill + topic shards): 2-3 days. Mostly content writing once schemas are documented.
|
||||
- **1.3.0** (schedule unification + remaining verbs + stream-json): 1 week. Each of the ~10 missing verbs is small but adds up.
|
||||
- **1.4.0** (resource-model rename + alias compat): 2-3 days.
|
||||
- **1.5.0** (policy engine + MCP allowlist): 4-5 days. Policy engine is its own subsystem — parser, evaluator, audit log, admin override.
|
||||
- **2.0.0** (strip tool handlers + cutover): 2 days.
|
||||
|
||||
Total: **~5-6 weeks of focused work** spread over 3-4 months calendar. Each release is independently shippable; the policy engine specifically can land later than 1.5 if needed.
|
||||
|
||||
## Acceptance signals — how we know it worked
|
||||
|
||||
- **ToolSearch** in a freshly-installed claudemesh session shows zero `mcp__claudemesh__*` entries by default (vs ~80 today).
|
||||
- **`claudemesh peers --json name,status`** projects exactly two fields, no extra noise.
|
||||
- **`claudemesh send <peer> "hi"`** from a Bash call inside a Claude session round-trips in <50ms (warm path via socket bridge) on localhost-broker, <250ms on EU-from-US.
|
||||
- **`Skill: claudemesh`** loaded once teaches Claude the entire mesh surface; subsequent CLI calls require no further introspection.
|
||||
- **A policy file with `decision: deny` for `file delete`** blocks the call before it hits the broker, with a clear stderr explanation.
|
||||
- **`claudemesh status set working` from cron** opens its own WS (no daemon), succeeds in <1s, no orphan connections on broker.
|
||||
155
.artifacts/specs/2026-05-02-handoff-evening.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# claudemesh handoff — 2026-05-02 (evening)
|
||||
|
||||
Companion to the morning handoff (`2026-05-02-handoff.md`). Captures
|
||||
what shipped through the v1.6.x patch line and the v1.7.0 demo cut.
|
||||
Read before the next session.
|
||||
|
||||
---
|
||||
|
||||
## What shipped this evening
|
||||
|
||||
### v1.6.x patch line — closed except bridge smoke test
|
||||
|
||||
| Feature | Endpoint / file | Commit |
|
||||
|---|---|---|
|
||||
| SSE topic stream | `GET /api/v1/topics/:name/stream` | `7e71a61` |
|
||||
| Unread counts | `PATCH /v1/topics/:name/read`, `unread` on `GET /v1/topics` | `a80eb6f` |
|
||||
| Mesh-card unread badges | `apps/web/src/app/[locale]/dashboard/(user)/page.tsx` | `541440c` |
|
||||
| Member sidebar | `GET /v1/members`, chat panel right rail | `a75483b` |
|
||||
| SSE 4xx-stop fix | `apps/web/src/modules/mesh/topic-chat-panel.tsx` | `7af61e1` |
|
||||
| Humans-as-peers | `GET /v1/peers` includes recent apikey users | `f4601f4` |
|
||||
|
||||
### v1.7.0 demo cut — 4 of 5 items shipped
|
||||
|
||||
| Item | Code | Commit |
|
||||
|---|---|---|
|
||||
| Member sidebar in chat | `apps/web/src/modules/mesh/topic-chat-panel.tsx` (+sidebar) | `a75483b` |
|
||||
| Topic search + autocomplete | Same file (+ search toggle, mention dropdown, clay highlight) | `35a289b`, `00c25d9` |
|
||||
| Notification feed | `MentionsSection` on universe + `GET /v1/notifications` | `a9160a0` |
|
||||
| Public blog post | `apps/web/src/app/[locale]/(marketing)/blog/agents-and-humans-same-chat/` | `69cf39b` |
|
||||
| Demo video script | `docs/demo-v1.7.0-script.md` (90s, 5 scenes) | `69cf39b` |
|
||||
| Marketing site refresh | Timeline next-block updated | `a2ab7de` |
|
||||
| **Recorded demo video** | — | **TODO (needs human + iTerm + Chrome)** |
|
||||
| **Marketing screenshots** | — | **TODO (needs Chrome session)** |
|
||||
|
||||
### Roadmap state
|
||||
|
||||
- `docs/roadmap.md` updated. v1.6.x marks every endpoint shipped except
|
||||
bridge smoke test. v1.7.0 marks sidebar/mentions/search/feed/blog
|
||||
shipped; recording + screenshots open.
|
||||
- v2.0.0 (daemon redesign) and v0.3.0 (operator layer / per-topic
|
||||
encryption) untouched — both still architectural specs.
|
||||
|
||||
---
|
||||
|
||||
## Live status
|
||||
|
||||
- **Broker** (`wss://ic.claudemesh.com/ws`): autodeployed via Coolify
|
||||
off the gitea-vps push. The custom migration runner from earlier
|
||||
this session is the one moving migrations forward. No new
|
||||
migrations shipped today — all v1.6.x work was code-only against
|
||||
the v0.2.0 schema.
|
||||
- **Web** (`claudemesh.com`): autodeployed via Vercel off the github
|
||||
push. Verified `/v1/notifications`, `/v1/peers`, `/v1/members`,
|
||||
`/v1/topics/general/stream`, `/v1/topics/general/read` all
|
||||
return 401 with bad bearer (i.e. they exist + auth works).
|
||||
Authenticated browser smoke not run — no Playwriter session
|
||||
available during this handoff write.
|
||||
- **CLI** (`claudemesh-cli@1.6.1` on npm): unchanged this session.
|
||||
All v1.6.x work was server + web only; CLI doesn't yet consume
|
||||
the new endpoints.
|
||||
|
||||
### CLI gap — worth noting
|
||||
|
||||
The new endpoints have NO CLI surface yet:
|
||||
|
||||
- `GET /v1/notifications` — `claudemesh notification list` could show
|
||||
recent mentions in the terminal. ~30 LoC.
|
||||
- `GET /v1/members` — `claudemesh member list` shows roster + online
|
||||
state. Distinct from `peer list` which shows live sessions.
|
||||
- `PATCH /v1/topics/:name/read` — could be implicit (called by
|
||||
`topic show <name>`) or explicit (`claudemesh topic read <name>`).
|
||||
- SSE stream — `claudemesh topic tail <name>` would tail messages
|
||||
in the terminal. High demo value.
|
||||
|
||||
Wiring these is a small CLI release (v1.7.0). Not blocking anything
|
||||
but worth doing before the recording so the demo includes a
|
||||
"terminal tail" cut.
|
||||
|
||||
---
|
||||
|
||||
## Known issues / risks
|
||||
|
||||
1. **Mentions notification endpoint depends on plaintext-base64
|
||||
ciphertext** that v0.2.0 ships. When per-topic encryption lands
|
||||
in v0.3.0, both `GET /v1/notifications` and the universe-page
|
||||
`MentionsSection` query break. Migration plan is documented in
|
||||
the blog post + the inline comment: move to a
|
||||
`mesh.notification` table populated at write time.
|
||||
|
||||
2. **Postgres `convert_from(decode(ciphertext, 'base64'), 'UTF8')`
|
||||
throws on any ciphertext that isn't valid base64-of-UTF8.** All
|
||||
current writers (broker WS path, REST POST /messages, web chat
|
||||
panel) emit base64-of-plaintext-UTF8, so this works. If a future
|
||||
writer emits binary ciphertext, the mention queries crash. Add a
|
||||
safe-base64 guard or migrate to per-write notification table
|
||||
before that happens.
|
||||
|
||||
3. **No live SSE smoke test in this session.** Endpoints respond
|
||||
401 to bad bearer. Browser-authenticated test was deferred — no
|
||||
Playwriter session was reachable during the run. Worth a
|
||||
manual smoke before recording the demo.
|
||||
|
||||
4. **CSRF middleware blocks PATCH/POST without an Origin header.**
|
||||
This is correct behaviour but trips up curl users. Documented
|
||||
in the smoke notes; not a bug.
|
||||
|
||||
---
|
||||
|
||||
## Next session — three branches
|
||||
|
||||
### A. Record + ship the v1.7.0 launch (~2 hours, all human work)
|
||||
1. Spin a fresh demo mesh + two iTerm panes running
|
||||
`claudemesh launch --name Mou` and `--name Alexis`.
|
||||
2. Run the demo script in `docs/demo-v1.7.0-script.md`.
|
||||
3. Cut to 90s, upload to `claudemesh.com/media/demo-v170.mp4`.
|
||||
4. Take 4-6 screenshots (universe, mesh detail, chat with sidebar,
|
||||
mentions feed, mobile view) for the blog hero + Twitter card.
|
||||
5. Cross-post per the script's distribution checklist.
|
||||
|
||||
### B. Wire CLI verbs to v1.6.x endpoints (~3 hours, code)
|
||||
1. `claudemesh notification list [--since]` → `GET /v1/notifications`.
|
||||
2. `claudemesh member list` → `GET /v1/members`.
|
||||
3. `claudemesh topic tail <name>` → SSE consumer. Print as messages
|
||||
arrive. Highest demo value.
|
||||
4. `claudemesh topic read <name>` → `PATCH /v1/topics/:name/read`.
|
||||
5. Bump `apps/cli/package.json` to 1.7.0, publish.
|
||||
|
||||
### C. v0.3.0 first slice — per-topic encryption (~5 hours, code)
|
||||
This is the next architectural cut.
|
||||
1. Schema: add `mesh.topic.encrypted_key` (encrypted-to-mesh-root).
|
||||
2. Broker: derive symmetric key on first message via HKDF; cache.
|
||||
3. Client: per-topic key fetch + `crypto_secretbox` over body.
|
||||
4. `ciphertext` column stops being plaintext-base64 → mentions
|
||||
query needs the notification table from issue #1.
|
||||
|
||||
Highest leverage right now is **A** (the recording is what turns
|
||||
shipped code into shipped product), then **B** (CLI parity makes
|
||||
the demo fuller). **C** is the next session for someone with
|
||||
2+ uninterrupted hours.
|
||||
|
||||
---
|
||||
|
||||
## Repo state
|
||||
|
||||
- `main` ahead of `gitea-vps/main` and `github/main` by 0 commits
|
||||
at handoff time — both pushed.
|
||||
- 12 commits this evening session (sse → unread → grid → sidebar →
|
||||
ssefix → mentions → search → notifications → roadmap → humans →
|
||||
roadmap2 → blog+demo → timeline).
|
||||
- No open PRs; everything went to main directly.
|
||||
- No `.skip` / TODO files / temp commits left behind.
|
||||
|
||||
---
|
||||
|
||||
*Last handoff: this file. Previous: `2026-05-02-handoff.md` (morning).*
|
||||
106
.artifacts/specs/2026-05-02-handoff.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# claudemesh handoff — 2026-05-02
|
||||
|
||||
State of the world after a long session that shipped 1.5.0 and the v0.2.0 backend. Read this before the next session — it captures what's done, what's deployed where, what's not, and the architectural decisions worth knowing.
|
||||
|
||||
---
|
||||
|
||||
## Where things stand
|
||||
|
||||
### Released to npm
|
||||
- **`claudemesh-cli@1.5.0`** (latest tag, published earlier today). CLI-first architecture lock-in: zero-tool MCP, policy engine, bundled `claudemesh` skill. Verified install + smoke-tested via clean `npm i -g`.
|
||||
|
||||
### In `main` but NOT released yet
|
||||
Everything below is committed, deployed to the broker (`wss://ic.claudemesh.com/ws`) and the web app (Vercel `claudemesh.com`), but **`claudemesh-cli@1.5.0` on npm doesn't have any of it**. Users won't see it until v1.6.0 publishes.
|
||||
|
||||
| Feature | Code path | Verified live? |
|
||||
|---|---|---|
|
||||
| Topics (schema, broker routing, CLI verbs, skill) | `packages/db/src/schema/mesh.ts`, `apps/broker/src/broker.ts`, `apps/cli/src/commands/topic.ts` | ✅ created `#deploys-test`, sent + persisted |
|
||||
| `apikey create/list/revoke` (CLI + broker WS) | `apps/cli/src/commands/apikey.ts`, broker dispatch | ✅ full lifecycle exercised |
|
||||
| REST `/api/v1/*` (messages, topics, peers, history) | `packages/api/src/modules/mesh/v1-router.ts` + `api-key-auth.ts` | ✅ posted via curl, history round-trips |
|
||||
| Bridge peer (SDK + CLI) | `packages/sdk/src/bridge.ts`, `apps/cli/src/commands/bridge.ts` | ⚠️ code only — never run end-to-end |
|
||||
|
||||
### Architectural commitments locked this session
|
||||
- **CLI-first, MCP push-pipe** (1.5.0): MCP `tools/list = []`. Inbound peer messages still arrive as `experimental.claude/channel` notifications. The bundled skill is the sole CLI-discoverability surface for Claude.
|
||||
- **Topics complement groups, don't replace them** (v0.2.0): mesh = trust boundary, group = identity tag, topic = conversation scope. Three orthogonal axes.
|
||||
- **Humans use REST + apikey, not browser WS** (v0.2.0): the broker already plumbs `peer_type: "human"`. The real blocker was browser-side ed25519, which we sidestep by exposing REST. Web chat UI = thin client over `/v1/*` using dashboard session auth.
|
||||
- **Spec lives at**: `.artifacts/specs/2026-05-02-architecture-north-star.md` (1.5.0) and `.artifacts/specs/2026-05-02-v0.2.0-scope.md` (v0.2.0 cut + design sketches).
|
||||
|
||||
---
|
||||
|
||||
## Three pending sessions, ranked by leverage
|
||||
|
||||
### Session A — Ship v1.6.0 npm release (~30 min, highest leverage)
|
||||
**Why first**: backend is feature-complete but unreleased. Users still get the no-topics 1.5.0.
|
||||
|
||||
Steps:
|
||||
1. Bump `apps/cli/package.json` 1.5.0 → 1.6.0.
|
||||
2. Update `apps/cli/README.md` migration note (mention topics, apikey, bridge).
|
||||
3. Add `## v1.6.0` section to `docs/roadmap.md`.
|
||||
4. Build + verify: `cd apps/cli && pnpm build && node dist/entrypoints/cli.js --version`.
|
||||
5. `npm publish --tag latest --access public --no-git-checks --ignore-scripts`.
|
||||
6. `git tag cli-v1.6.0 && git push github cli-v1.6.0` — workflow builds 5 binaries + auto-bumps Homebrew/winget tap.
|
||||
7. Verify on a clean prefix: `PREFIX=/tmp/cm16 mkdir -p $PREFIX && npm install -g --prefix $PREFIX claudemesh-cli@1.6.0 && $PREFIX/bin/claudemesh --help | grep -E "topic|apikey|bridge"`.
|
||||
|
||||
### Session B — Migration drift fix (~1 day, highest pain reduction)
|
||||
**Why second**: every schema change today requires manual `psql -f migration.sql` against prod. The drizzle `_journal.json` stops at idx 11, runtime migrator silently skips anything not in journal. Today's `0022_topics.sql` and `0023_api_keys.sql` were applied by hand. **Future migrations will keep needing this until fixed.**
|
||||
|
||||
Recommended approach:
|
||||
1. Replace `drizzle-orm/postgres-js/migrator` in `apps/broker/src/migrate.ts` with a custom runner.
|
||||
2. Scan `migrations/*.sql` lexicographically (already named `NNNN_*.sql`).
|
||||
3. Track applied filenames in a new `mesh.__cmh_migrations` table (filename + sha256 + applied_at).
|
||||
4. On startup: filter unapplied files, run them in transaction order under `pg_try_advisory_lock`. Fail loud on hash mismatch (catches edits after deploy).
|
||||
5. Backfill the table with all 0000-0023 entries one-time so prod is consistent.
|
||||
6. Drop the drizzle journal usage entirely (`migrations/meta/_journal.json` becomes dead state).
|
||||
|
||||
This unblocks every future feature touching DB.
|
||||
|
||||
### Session C — Web chat UI (~2-3 days, highest visibility)
|
||||
**Why third**: the demo. Backend is ready; this is pure React + REST.
|
||||
|
||||
Path: `apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/topics/[name]/page.tsx` (new).
|
||||
|
||||
Components needed:
|
||||
- Topic header (members count, settings button).
|
||||
- Message stream — `GET /api/v1/topics/:name/messages?limit=50`. Poll every 5s for new (no WS yet — REST polling is fine for v0.2.0).
|
||||
- Compose box — `POST /api/v1/messages` with `{topic, ciphertext, nonce}`.
|
||||
- Members sidebar — `GET /api/v1/peers`.
|
||||
- Apikey lifecycle: on first load, server-side issue an apikey for the dashboard user (using their existing NextAuth session) scoped to `read,send` on this topic. Stash in browser session storage.
|
||||
|
||||
Server-side helper for apikey issuance lives in `packages/api/src/modules/mesh/api-key-auth.ts` — refactor `verifyBearer` to also expose a `createApiKeyForUser(userId, meshId, scope)` helper for the dashboard handler.
|
||||
|
||||
---
|
||||
|
||||
## Three less-urgent followups (don't block sessions A-C)
|
||||
|
||||
1. **Bridge end-to-end smoke test**: never actually run between two meshes. Needs second test mesh + bridge member onboarding ritual. Worth doing before any blog post / external demo.
|
||||
2. **`/v1/peers` includes only WS-connected agents**, not humans (since humans are REST-only and never appear in `presence`). Decide: synthetic presence rows for active apikey sessions? Or document that `/v1/peers` is "agents online"?
|
||||
3. **Topic ciphertext is plaintext base64** in the current implementation — no actual encryption. The schema names it `ciphertext` for forward-compat, but the code base64-encodes UTF-8. Real per-topic symmetric key derivation (HKDF from mesh root_key + topic_id) is a v0.3.0 item.
|
||||
|
||||
---
|
||||
|
||||
## Production state worth knowing
|
||||
|
||||
- **Broker**: `wss://ic.claudemesh.com/ws`, deployed via Coolify on OVHcloud VPS. Auto-redeploys on push to `gitea-vps main`. Deploy ETA ~3 min.
|
||||
- **Web**: `claudemesh.com`, Vercel auto-deploy on push to `github main`. Deploy ETA ~2 min.
|
||||
- **Postgres**: container `eo1f5gydsgrg19b57e9s4zw7` on the VPS. SSH via `ssh ovh`, then `docker exec eo1f5gydsgrg19b57e9s4zw7 psql -U claudemesh -d claudemesh`.
|
||||
- **Test mesh**: `openclaw` on the same broker has 5 active peers and one topic (`#deploys-test`).
|
||||
- **Active apikey** (from earlier today's smoke): `cm_OC12dRti…` was revoked. None active right now.
|
||||
|
||||
---
|
||||
|
||||
## Files most worth reading first in next session
|
||||
|
||||
1. `.artifacts/specs/2026-05-02-architecture-north-star.md` — the 7 architectural commitments.
|
||||
2. `.artifacts/specs/2026-05-02-v0.2.0-scope.md` — design sketches for topics, REST, bridge.
|
||||
3. `apps/cli/skills/claudemesh/SKILL.md` — the canonical CLI surface; ships in npm tarball.
|
||||
4. This file.
|
||||
|
||||
---
|
||||
|
||||
## Memory not yet captured
|
||||
|
||||
Worth adding to `~/.claude/projects/-Users-agutierrez-Desktop-claudemesh/memory/MEMORY.md` next session:
|
||||
|
||||
- **Drizzle journal drift is a recurring trap** — manual psql until session B lands. Save the exact apply ritual: `scp migrations/NNNN.sql ovh:/tmp/ && ssh ovh "docker cp /tmp/NNNN.sql <pg-container>:/tmp/ && docker exec <pg-container> psql -U claudemesh -d claudemesh -f /tmp/NNNN.sql"`.
|
||||
- **`workspace:*` deps break `npm publish`** — keep SDK as devDependency in `apps/cli/package.json`; Bun bundles it into dist so runtime doesn't need it. Same trick for any other workspace-only build deps.
|
||||
- **Commitlint hard-caps body lines at 100 chars** — use `git commit -F /tmp/cm-commit.txt` rather than `-m` heredocs. Heredocs that exceed the limit fail the husky hook silently.
|
||||
227
.artifacts/specs/2026-05-02-roadmap.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# claudemesh internal roadmap — 2026-05-02
|
||||
|
||||
Strategic counterpart to `docs/roadmap.md` (which is the public, marketing-tone roadmap). This file captures the *why*, the dependencies, the costs, and the things we deliberately won't do.
|
||||
|
||||
Anchored in the v0.2.0 backend cut + `#general` auto-creation + filename-tracked migrator + owner-member backfill that all shipped 2026-05-02.
|
||||
|
||||
---
|
||||
|
||||
## Forcing function
|
||||
|
||||
> **Ship v1.6.x in 2 weeks. Ship v1.7.0 in a month. Make the demo. Then commit the daemon.**
|
||||
|
||||
Each release stands on its own — usable and shippable even if the next slips. That's the property to optimize for, not "fastest path to v3.0.0."
|
||||
|
||||
---
|
||||
|
||||
## Schedule
|
||||
|
||||
| When | Version | Theme | Status |
|
||||
|---|---|---|---|
|
||||
| Now | 1.6.0 | v0.2.0 backend cut | ✅ shipped 2026-05-02 |
|
||||
| +2w | 1.6.x | Demo polish (SSE, unread, sidebar) | Active |
|
||||
| +5w | 1.7.0 | First marketing-ready version | Planned |
|
||||
| +9w | 2.0.0 | Daemon redesign | Planned |
|
||||
| +15w | 0.3.0 | Self-hosted + per-topic encryption + gateways | Planned |
|
||||
| TBD | 3.0.0 | Native Claude channels | Anthropic-gated |
|
||||
|
||||
≈4 months from today to a teams-can-self-host shape. The MCP bridge stays load-bearing the whole time but stops being the user's problem at v2.0.0.
|
||||
|
||||
---
|
||||
|
||||
## v1.6.x patch line — 0-2 weeks, polish what's deployed
|
||||
|
||||
| Item | Effort | Why now |
|
||||
|---|---|---|
|
||||
| Real-time push (SSE on `/api/v1/topics/:name/stream`) | 2 days | Chat lag is the only user-visible v0.2.0 wart. Replaces 5s polling. |
|
||||
| Unread counts via `last_read_at` | ½ day | Schema column already exists. PATCH on scroll-to-bottom + chip on topic list. |
|
||||
| Bridge end-to-end smoke (two-mesh forwarding test) | ½ day | Feature shipped, never validated. Catches obvious bugs before any external demo. |
|
||||
| Drizzle journal + `meta/` cleanup | 1 hour | Inert dead files since the new runner. Low-risk cosmetic. |
|
||||
| `/v1/peers` includes humans (synthetic presence rows for active apikeys) | 1 day | Today the dashboard chat user is invisible to other peers. |
|
||||
|
||||
Total: ~1 week of focused work. Closes the v0.2.0 backend chapter cleanly.
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0 — 2-3 weeks, the demo cut
|
||||
|
||||
The release that turns claudemesh into a thing you can record and show.
|
||||
|
||||
**Scope:**
|
||||
- Member sidebar in the chat panel — names, online dots, presence summaries. Comes nearly free with SSE from v1.6.x.
|
||||
- Topic search + member-mention autocomplete — `@Mou` hot-keys to `claudemesh send Mou ...`.
|
||||
- Notification feed at `/dashboard` — "you have N unread in #deploys, 2 mentions in #incident." Purely aggregate; no new schema.
|
||||
- One-line marketing site refresh — capture screenshots from the now-real-time UI, drop the v0.2.0 stamp from the chat footer, update README/landing.
|
||||
- First public blog post + recorded demo — "claudemesh in 90 seconds" video. Triggers the first proper user-acquisition push.
|
||||
|
||||
**Not in scope:** any architectural change. v1.7.0 is pure UX polish on top of the v1.6.x foundation. Architecture work waits for v2.0.0.
|
||||
|
||||
**Why this comes before v2.0.0:** without users, the daemon is a solution for nobody. v1.7.0 produces the first real user signal so v2.0.0 has data to optimize against.
|
||||
|
||||
---
|
||||
|
||||
## v2.0.0 — 3-4 weeks, the daemon redesign
|
||||
|
||||
The single largest architectural shift on the roadmap. Background and rationale captured at length elsewhere this session; summary here.
|
||||
|
||||
### Single load-bearing principle
|
||||
|
||||
> **The user is the unit of mesh participation, not the Claude session.**
|
||||
|
||||
Every weird edge case from this session — the launch tax, the orphan owner, the per-session keypair churn, the MCP install/uninstall ritual, multi-Claude config corruption — comes from getting this one thing wrong today. Fix it once, structurally, and 70% of accumulated complexity vanishes.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
claudemesh.com (web identity + workspace admin)
|
||||
│
|
||||
▼ JWT
|
||||
broker (unchanged) — wss://ic.claudemesh.com/ws
|
||||
│
|
||||
▼ ws per workspace
|
||||
claudemesh-daemon (per user, launchd/systemd, persistent)
|
||||
│
|
||||
▼ unix socket
|
||||
┌────┴────┐
|
||||
▼ ▼
|
||||
CLI verbs MCP push-pipe (~50 LoC)
|
||||
│
|
||||
▼
|
||||
claude (any number of sessions)
|
||||
```
|
||||
|
||||
### What v2.0.0 ships
|
||||
|
||||
- **`claudemesh-daemon`** — long-lived per-user process. One WS per workspace, kept alive across Claude session lifetimes. Listens on `~/.claudemesh/sockets/<workspace>.sock`. Started by `claudemesh login`, persists across reboots.
|
||||
- **HKDF-derived peer keypairs from JWT** — same identity across machines, no key copy ritual. Web sign-up = CLI sign-up = same row in `mesh_member`.
|
||||
- **Stateless CLI verbs** — each existing command (`send`, `peers`, `topic`, `apikey`, `bridge`, `state`, `remember`, etc.) retargeted to dial the daemon socket. ~3000 LoC of plumbing deleted, ~500 LoC of glue added.
|
||||
- **50-line MCP server** — dial daemon, forward inbound peer messages as `experimental.claude/channel` notifications. The push-pipe shrinks from ~150 LoC to ~50.
|
||||
- **`claudemesh launch` deprecated** — replaced by ambient mode: `claude` with no flags. Launch becomes a one-line alias that prints "ambient mode now, just run `claude`" and exits.
|
||||
- **"Mesh" → "workspace"** in the public surface. DB tables keep `mesh_*` names for migration sanity.
|
||||
|
||||
### What v2.0.0 kills
|
||||
|
||||
- `claudemesh launch` command — the 8-thing bootstrap was paying for state the daemon now owns persistently.
|
||||
- `--dangerously-skip-permissions` — set once at install in `settings.json` allowedTools, never seen by the user again.
|
||||
- `--dangerously-load-development-channels` — written into `~/.claude.json` once at install, never seen again.
|
||||
- Per-session `CLAUDEMESH_CONFIG_DIR` tmpdir — daemon owns config.
|
||||
- Per-session `CLAUDEMESH_DISPLAY_NAME` env var — daemon stores it.
|
||||
- MCP install/uninstall ritual on every launch — MCP entry is permanent.
|
||||
- Multi-Claude config corruption — only the daemon writes config.
|
||||
- Orphan-owner bug (just fixed via backfill) — structurally impossible because web sign-up creates the member row.
|
||||
|
||||
### What v2.0.0 keeps
|
||||
|
||||
- Wire protocol, crypto primitives, broker schema — 100% unchanged.
|
||||
- All CLI verb names — 100% unchanged (just retargeted).
|
||||
- REST `/api/v1/*` surface — 100% unchanged.
|
||||
- Web chat UI — 100% unchanged.
|
||||
- Bridge peer feature — 100% unchanged.
|
||||
- Topic semantics, ciphertext field, ephemeral DMs — 100% unchanged.
|
||||
|
||||
### Cost
|
||||
|
||||
- ~3 weeks focused engineering
|
||||
- ~30% LoC reduction in the CLI package
|
||||
- ~80% reduction in support load for "launch flags," "config corruption," "peer keypair lost," "owner has no member row"
|
||||
- ~0 cost to broker, web app, schema, protocol — none of the deep parts change
|
||||
|
||||
### Migration path (backwards-compatible at every step)
|
||||
|
||||
1. **Week 1** — daemon binary + unix socket protocol + retarget two CLI verbs (`send`, `peers`) as the smoke test. Ship to alpha testers.
|
||||
2. **Week 2** — retarget remaining verbs. HKDF-keypair migration with a one-shot `claudemesh migrate-identity` command for existing users.
|
||||
3. **Week 3** — `claudemesh launch` becomes a deprecated alias. MCP server retargeted to daemon socket. Backfill: every existing user's daemon spins up on first `claudemesh` invocation.
|
||||
4. **Cut v2.0.0**: remove deprecated launch alias one minor release later (v2.1.0) once metrics show no one's hitting it.
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 — 4-6 weeks, the operator chapter
|
||||
|
||||
For teams that want to run their own broker, encrypt at the topic level, or wire claudemesh to messaging surfaces beyond Claude Code.
|
||||
|
||||
- **Per-topic HKDF encryption** — kills the "broker can read your messages" wart. Symmetric key derived from `mesh.root_key + topic.id`. Web client gets the topic key from the sealed root_key it already holds.
|
||||
- **Self-hosted broker packaging** — single `docker-compose.yml`, postgres included. CLI accepts `--broker wss://...` to point anywhere. Federation primer.
|
||||
- **WhatsApp gateway** — peer bot that forwards a topic to a WhatsApp group.
|
||||
- **Telegram gateway** — same pattern.
|
||||
- **Tag routing** — `claudemesh send tag:repo:billing "deployed"` lands at every peer working on that repo. Already protocol-supported, needs CLI ergonomics + dashboard surface.
|
||||
|
||||
v0.3.0 is when teams that want to run their own broker can do so without paying us. Counterintuitively important: it's also when we can charge for hosted with a clean conscience.
|
||||
|
||||
---
|
||||
|
||||
## v3.0.0 — Anthropic-blessed cut (conditional)
|
||||
|
||||
Conditional on Anthropic shipping first-class agent-to-agent channels in Claude Code. We don't control the timing.
|
||||
|
||||
### What's load-bearing about today's flag
|
||||
|
||||
`--dangerously-load-development-channels server:claudemesh` does two things:
|
||||
|
||||
1. Loads the claudemesh MCP server.
|
||||
2. Tells Claude Code to treat its `experimental.claude/channel` notifications as runtime channel events.
|
||||
|
||||
The flag is named `dangerously-load-development-channels` *specifically because* the channel API is experimental and unstable. Some opt-in mechanism will always be required for Claude Code to receive external events from a third-party process — that's a security-model invariant, not a quirk of today's flag. What changes at v3.0.0 is the *form* of the opt-in, not its existence.
|
||||
|
||||
### Two scenarios depending on Anthropic's choice
|
||||
|
||||
**Scenario A — MCP-channel API graduates.** The same MCP-based push primitive becomes stable.
|
||||
- MCP wrapper stays (still translates `ws://broker → MCP notification`).
|
||||
- The `--dangerously-load-development-channels` flag is replaced by a stable settings.json entry — e.g. `mcpServers.claudemesh.acceptChannelNotifications = true`.
|
||||
- The `experimental.` prefix on the notification namespace goes away.
|
||||
- Net user-visible change: nothing, because we already write the flag once at install and the user never sees it. The migration is internal: swap the install logic to write the new settings entry instead of the old flag.
|
||||
|
||||
**Scenario B — non-MCP transport ships.** Anthropic introduces a sidecar IPC, a native WebSocket subscription declared in settings, or some other primitive.
|
||||
- The 50-line MCP wrapper from v2.0.0 disappears.
|
||||
- The daemon plugs into the new transport directly.
|
||||
- Some opt-in config is still required (settings.json entry, environment variable, etc.) — Claude Code must know to subscribe to the daemon's channel.
|
||||
- Net user-visible change: still nothing if our `claudemesh install` adapts to write the new opt-in form.
|
||||
|
||||
### What disappears regardless
|
||||
|
||||
- The `experimental.` prefix on the channel API (it stabilizes).
|
||||
- The `dangerously-` framing of the flag (the API is no longer experimental).
|
||||
- The "you have to pass a launch flag to load development channels" mental model.
|
||||
|
||||
### What stays regardless
|
||||
|
||||
- An opt-in mechanism somewhere (security model invariant).
|
||||
- The daemon as the lifecycle owner.
|
||||
- The protocol, schema, broker, topics, web chat — all unchanged.
|
||||
|
||||
### Marketing pivot
|
||||
|
||||
claudemesh becomes a "hosted backend for Claude's native multi-agent feature" rather than a "Claude Code extension." The product story simplifies regardless of which shape ships, because the user no longer has to think about MCP servers, dangerous flags, or experimental APIs — claudemesh is just there.
|
||||
|
||||
Until v3.0.0 lands, v2.x ships with the MCP bridge under the existing flag. v3.0.0 is the migration target, not a planned feature.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting tracks (always-on, not version-gated)
|
||||
|
||||
| Track | What it covers | Target version |
|
||||
|---|---|---|
|
||||
| Mobile | iOS peer app (thin: push + reply, same JWT identity) | v2.x |
|
||||
| Browser peer (proper) | IndexedDB ed25519 + WebCrypto crypto_box for the dashboard. Today's web is REST-only; this makes it a true peer. | v2.x |
|
||||
| Peer transcript queries | "Hey Claude2, what have you touched in the last hour?" cross-session memory primitive | v0.3.0+ |
|
||||
| Mesh analytics | Volume, presence, handoff latency dashboards | v0.3.0 |
|
||||
| Slack peer (first-party) | Today: build-your-own. Shipped natively. | v0.3.0 |
|
||||
|
||||
---
|
||||
|
||||
## Deliberate exclusions
|
||||
|
||||
| Idea | Why deferred |
|
||||
|---|---|
|
||||
| Custom bot framework / plugin marketplace | Premature — claudemesh barely has organic users. Build the user base first, then platform. |
|
||||
| Voice channels | Out of scope. Different product. |
|
||||
| Video chat | Same. |
|
||||
| Email-as-peer (incoming SMTP → mesh) | Has demand from one user; ship if 3+ ask. |
|
||||
| AI summarization of channels | LLM cost + scope creep. Users can wire their own with the existing message API. |
|
||||
| Mobile push notifications via APNs/FCM | Wait for the iOS peer app, then revisit. |
|
||||
| Reactions / threading | Not yet — would muddle the protocol surface for marginal value. Reconsider after v0.3.0 user feedback. |
|
||||
|
||||
---
|
||||
|
||||
## Single-sentence summary
|
||||
|
||||
**Polish v1.6.x → ship v1.7.0 demo → commit v2.0.0 daemon → open the operator chapter at v0.3.0 → plug into native channels at v3.0.0 when Anthropic ships them.** Each release stands on its own. The protocol, the schema, the broker, and the topics are all already correct — what changes is the lifecycle owner around them.
|
||||
178
.artifacts/specs/2026-05-02-topic-key-onboarding.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Topic-key onboarding — v0.3.0 phase 2
|
||||
|
||||
The schema for per-topic encryption is shipped (migration 0026). The
|
||||
broker generates a 32-byte XSalsa20-Poly1305 key when a topic is
|
||||
created and seals one copy for the creator via `crypto_box`. The open
|
||||
question is **how new joiners get their sealed copy** without giving
|
||||
the broker the plaintext.
|
||||
|
||||
This spec covers the three live options, picks one for v0.3.0 phase 2,
|
||||
and parks the rest as future cuts. Implementation is **not in this
|
||||
spec** — that follows once we ship the chosen flow.
|
||||
|
||||
---
|
||||
|
||||
## The constraint
|
||||
|
||||
The broker holds:
|
||||
|
||||
- `topic.encrypted_key_pubkey` — the ephemeral x25519 pubkey used to
|
||||
seal each member's copy. Public. The matching secret is **discarded
|
||||
immediately after creation** — only the topic creator's session
|
||||
knows the topic key briefly during sealing, then it leaves memory.
|
||||
- `topic_member_key.(encrypted_key, nonce)` — per-member sealed
|
||||
ciphertext.
|
||||
|
||||
The broker **must not** be able to decrypt any sealed copy. So when a
|
||||
new member joins a topic that already exists, the broker can't seal a
|
||||
copy for them by itself.
|
||||
|
||||
## Option A — server-side escrow (REJECTED)
|
||||
|
||||
Broker holds the topic key encrypted under its own service key + per-
|
||||
member sealed copies. Re-sealing for new members is a server-only
|
||||
operation.
|
||||
|
||||
**Why rejected:** the broker can read every message in every topic
|
||||
forever. Calling that "per-topic encryption" misleads users. Worse
|
||||
than today's plaintext-base64 because it implies a security property
|
||||
the design doesn't deliver.
|
||||
|
||||
## Option B — member-driven re-seal (CHOSEN for phase 2)
|
||||
|
||||
When a new member joins, an existing member's CLIENT decrypts their
|
||||
own sealed copy of the topic key, then seals a new copy for the
|
||||
joiner and POSTs it to the broker.
|
||||
|
||||
**Wire:**
|
||||
|
||||
1. New member joins via `claudemesh topic join <topic>` — broker
|
||||
inserts `topic_member` row, no `topic_member_key` row.
|
||||
2. New member calls `GET /v1/topics/:name/key` → 404 with
|
||||
`key_not_sealed_for_member`.
|
||||
3. Existing online members (any of them) periodically poll
|
||||
`GET /v1/topics/:name/pending-seals` (new endpoint) and see the
|
||||
new joiner.
|
||||
4. Existing member's client:
|
||||
- Decrypts their own sealed copy via `crypto_box_open` with their
|
||||
x25519 secret + `topic.encrypted_key_pubkey`.
|
||||
- Generates a fresh ephemeral x25519 keypair.
|
||||
- Seals the topic key for the joiner via `crypto_box` with the
|
||||
joiner's pubkey + the new ephemeral.
|
||||
- POSTs the result to `POST /v1/topics/:name/seal`.
|
||||
5. Broker stores the new `topic_member_key` row.
|
||||
6. New member's `GET /v1/topics/:name/key` now returns 200.
|
||||
|
||||
**Trust model:** broker never sees plaintext. Assumes at least one
|
||||
existing member is online when the joiner connects. Worst case the
|
||||
joiner waits — UI shows "waiting for a peer to share the topic key"
|
||||
until somebody seals.
|
||||
|
||||
**Open detail — sender pubkey identity:** each re-seal uses a fresh
|
||||
ephemeral pubkey. Either:
|
||||
|
||||
(a) Store ALL ephemeral pubkeys ever used to seal copies of this
|
||||
topic, indexed by member, so the joiner can pick the right one
|
||||
when decrypting. Adds a new table.
|
||||
(b) Embed the ephemeral pubkey in the sealed payload itself (
|
||||
`encrypted_key` becomes `<32-byte ephem_pubkey><crypto_box_easy>`).
|
||||
Decoder pulls the prefix, uses it as the sender pubkey. No schema
|
||||
change beyond what 0026 already ships.
|
||||
|
||||
**(b) wins on simplicity. Phase 3 implementation ships it. Both the
|
||||
broker creator-seal and the CLI re-seal write the
|
||||
`<32-byte sender pubkey><cipher>` blob.** `topic.encrypted_key_pubkey`
|
||||
becomes informational only — the wire-format truth is the inline prefix.
|
||||
|
||||
## Web client gap (phase 3.5)
|
||||
|
||||
The CLI side of phase 3 ships in this cut. The web side does NOT —
|
||||
because web member rows have `peerPubkey` registered server-side but
|
||||
the corresponding ed25519 SECRET is discarded immediately after
|
||||
generation (see `mutations.ts:createMyMesh`). Without the secret the
|
||||
browser can't `crypto_box_open` its sealed topic key.
|
||||
|
||||
Three fixes, in increasing order of effort:
|
||||
|
||||
1. **Browser-side persistent identity (recommended)** — generate an
|
||||
ed25519 keypair in the browser on first dashboard visit, store the
|
||||
secret in IndexedDB, sync the public half to `mesh.member.peerPubkey`
|
||||
via a new `POST /v1/me/peer-pubkey` endpoint. Topic keys then seal
|
||||
to the new pubkey; web user decrypts locally. Existing #general
|
||||
topics need a re-seal cycle (the v0.3.0 phase-3 re-seal loop in
|
||||
the CLI already does this for any pending member, including web
|
||||
ones). Spec lift: ~3 hours, mostly browser code + a sync endpoint.
|
||||
|
||||
2. **Server-held secret** — keep the member's ed25519 secret server-
|
||||
side. Trivial to implement, but the broker can read everything,
|
||||
defeating the security claim. **Rejected.**
|
||||
|
||||
3. **JWT-derived keys** — derive the member's keypair from a stable
|
||||
user-secret (e.g. PBKDF2 over their session JWT). Means cross-
|
||||
device same key, but needs the JWT to include ~32 bytes of stable
|
||||
key material. Tied to v2.0.0 daemon redesign. **Deferred.**
|
||||
|
||||
Phase 3 ships option 1 deferred; web stays on v1 plaintext until 3.5.
|
||||
The CLI re-seal loop in `topic tail` already handles re-sealing for
|
||||
web members ONCE they have a real pubkey — no broker work needed
|
||||
when 3.5 lands.
|
||||
|
||||
## Option C — leaderless protocol (DEFERRED)
|
||||
|
||||
MLS, TreeKEM, or similar continuous group key agreement. Right answer
|
||||
for groups >50 members. Overkill for v0.3.0 — implementation cost is
|
||||
4-6 weeks of focused work, and the threat model gain over Option B
|
||||
only matters if we believe a member's machine can be silently
|
||||
compromised long enough to leak the topic key but short enough that
|
||||
they aren't kicked from the topic.
|
||||
|
||||
Park for v0.4.0 or v0.5.0. Revisit when we onboard a customer that
|
||||
asks for FS (forward secrecy) on group chat.
|
||||
|
||||
---
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
Schema (0026 — done):
|
||||
- [x] `topic.encrypted_key_pubkey` (informational; wire truth is the
|
||||
inline 32-byte prefix on each `topic_member_key.encryptedKey`)
|
||||
- [x] `topic_member_key.(encrypted_key, nonce)`
|
||||
- [x] `topic_message.body_version` (1 = plaintext, 2 = v2 ciphertext)
|
||||
|
||||
API (phase 3 — done):
|
||||
- [x] `GET /v1/topics/:name/key` — fetch the calling member's sealed copy
|
||||
- [x] `GET /v1/topics/:name/pending-seals` — list members without keys
|
||||
- [x] `POST /v1/topics/:name/seal` — submit a re-sealed copy
|
||||
- [x] `GET /v1/topics/:name/messages` returns `bodyVersion`
|
||||
- [x] `GET /v1/topics/:name/stream` emits `bodyVersion`
|
||||
- [x] `POST /v1/messages` accepts `bodyVersion` (1|2) + skips regex
|
||||
mention extraction on v2
|
||||
|
||||
Broker / web mutation (phase 3 — done):
|
||||
- [x] `createTopic` generates topic key + seals for creator with
|
||||
inline-sender-pubkey blob format
|
||||
- [x] `ensureGeneralTopic` (web) mirrors the same flow
|
||||
|
||||
Client — CLI (phase 3 — done):
|
||||
- [x] `services/crypto/topic-key.ts` — fetch + decrypt + encrypt + reseal helpers
|
||||
- [x] `topic tail` decrypts v2 messages on render
|
||||
- [x] `topic post` encrypts v2 on send via REST POST /v1/messages
|
||||
- [x] Background re-seal loop in `topic tail` (30s cadence)
|
||||
|
||||
Client — web (phase 3.5 — DEFERRED):
|
||||
- [ ] Browser-side persistent identity (IndexedDB)
|
||||
- [ ] `POST /v1/me/peer-pubkey` sync endpoint
|
||||
- [ ] Web chat panel encrypt-on-send + decrypt-on-render (currently v1)
|
||||
|
||||
UX surfaces (phase 3 — done in CLI):
|
||||
- [x] "waiting for a peer to share the topic key" warning on tail
|
||||
- [ ] (web) "your encryption keys are pending — pair this browser"
|
||||
banner once 3.5 lands
|
||||
|
||||
Mention fan-out from phase 1 already works for both v1 and v2
|
||||
messages, so `/v1/notifications` keeps working through the cutover.
|
||||
|
||||
The phase-3 cut ships full CLI encryption + re-seal flow. Web remains
|
||||
on v1 plaintext until 3.5 lands the browser identity layer. Mixed
|
||||
CLI+web meshes in the meantime should keep using v1 sends OR accept
|
||||
that web members can't read v2 messages.
|
||||
273
.artifacts/specs/2026-05-02-v0.2.0-scope.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# claudemesh v0.2.0 — scope
|
||||
|
||||
**Date:** 2026-05-02
|
||||
**Status:** draft
|
||||
**Predecessor:** [`2026-05-02-architecture-north-star.md`](./2026-05-02-architecture-north-star.md) (1.5.0 architecture lock)
|
||||
|
||||
---
|
||||
|
||||
## Cut
|
||||
|
||||
**Theme: from agent-only mesh to mesh of agents, humans, and external systems — with conversation context.**
|
||||
|
||||
| # | Feature | Effort | Spine |
|
||||
|---|---------|--------|-------|
|
||||
| 1 | **Topics** (channels/rooms within a mesh) | 2-3 d | yes |
|
||||
| 2 | **Humans in the mesh** (web chat panel) | 2-3 d | depends on #1 |
|
||||
| 3 | **REST API + external WS** (API keys per mesh) | 2-3 d | depends on #1 |
|
||||
| 4 | **Bridge peer** (forwards one topic between meshes) | 1 d | depends on #1 |
|
||||
|
||||
Optional pickup if all four ship early:
|
||||
- **Local peer aliases** (~0.5 d) — IRC-style local labels for hard-to-remember displayNames.
|
||||
- **Semantic peer search** (~0.5 d) — already in vision doc; useful once topics exist.
|
||||
|
||||
Total: 7-9 days plus 1-2 days slack. Targeting **release window: 2026-05-12 to 2026-05-16**.
|
||||
|
||||
---
|
||||
|
||||
## Why this cut
|
||||
|
||||
The 1.5.0 architecture (CLI-first, tool-less MCP, policy engine) is finished. The next bottleneck is **product surface**, not engineering.
|
||||
|
||||
Current taxonomy `mesh + group + role` is the right *organizational* structure but missing a *conversational* primitive. Every message is DM or `@group` broadcast — there's no continuity for "the deploys conversation," no scoped state/memory/files, no way for a human to join a topic without joining the whole mesh, no way for a bridge to forward a single thread of work.
|
||||
|
||||
**Topics fix this.** They are the spine of v0.2.0:
|
||||
- Without topics, "humans in mesh" floods every human with every peer's chatter.
|
||||
- Without topics, "bridge" forwards everything (loop risk, signal-to-noise problem).
|
||||
- Without topics, REST API endpoints have no natural sub-mesh scope.
|
||||
|
||||
Once topics exist, humans + REST + bridge each become 50% smaller because they slot into a clean primitive instead of inventing one.
|
||||
|
||||
---
|
||||
|
||||
## Deferred
|
||||
|
||||
| Item | Why later |
|
||||
|---|---|
|
||||
| **Federation** (broker-to-broker) | Bridges prototype it. Learn from real use first. |
|
||||
| **Sandboxes** (E2B / Modal) | Orthogonal capability. Separate release. |
|
||||
| **Sim SDK** (`@claudemesh/sim`) | Niche audience; long-tail. v0.3.0+. |
|
||||
| **Welcome back / persistent MCP** | Already in progress as 1.6.0 patch. |
|
||||
| **Mesh telemetry** | Pre-PMF telemetry is busywork; users first. |
|
||||
|
||||
---
|
||||
|
||||
## Design sketches
|
||||
|
||||
### 1. Topics
|
||||
|
||||
**Mental model:** mesh is *who you trust*; group is *who you are*; topic is *what you're talking about*. Three orthogonal axes.
|
||||
|
||||
**Wire shape:**
|
||||
|
||||
```yaml
|
||||
topic:
|
||||
id: <ulid>
|
||||
mesh_slug: openclaw
|
||||
name: deploys # unique within mesh
|
||||
description: "deploy + on-call"
|
||||
visibility: public # public | private (invite-only) | dm (1:1, autocreated)
|
||||
created_by: <pubkey>
|
||||
created_at: <ts>
|
||||
```
|
||||
|
||||
**Membership:**
|
||||
|
||||
```yaml
|
||||
topic_member:
|
||||
topic_id: <ulid>
|
||||
pubkey: <hex> # session pubkey OR member_pubkey for durable identity
|
||||
role: lead | member | observer
|
||||
joined_at: <ts>
|
||||
last_read_at: <ts> # for unread counts
|
||||
```
|
||||
|
||||
**Messages reference a topic, not just a target:**
|
||||
|
||||
```jsonc
|
||||
// existing send_message envelope gains a `topic` field
|
||||
{
|
||||
"to": "@deploys", // or topic id, or peer name (DM)
|
||||
"topic": "deploys", // optional explicit, inferred from `to: @<topic>`
|
||||
"message": "...",
|
||||
"priority": "next"
|
||||
}
|
||||
```
|
||||
|
||||
**Resolution rules:**
|
||||
- `to: "alice"` → DM to peer alice (no topic).
|
||||
- `to: "@frontend"` → group broadcast (no topic — backwards compatible with 1.5.0).
|
||||
- `to: "#deploys"` → topic message; delivered only to topic subscribers.
|
||||
- `to: "*"` → mesh-wide broadcast (kept; lower-priority than topic for new comms).
|
||||
|
||||
**State/memory/files scoping:**
|
||||
- `claudemesh state set <k> <v> --topic deploys` — namespace under topic.
|
||||
- `claudemesh remember "..." --topic deploys` — topic-scoped memory.
|
||||
- `claudemesh file list --topic deploys` — files visible only to topic members.
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
claudemesh topic create deploys --description "deploy + on-call"
|
||||
claudemesh topic list # all topics in mesh
|
||||
claudemesh topic join deploys
|
||||
claudemesh topic leave deploys
|
||||
claudemesh topic invite deploys <peer> # private topics
|
||||
claudemesh topic members deploys
|
||||
claudemesh topic delete deploys # creator/admin only
|
||||
claudemesh send "#deploys" "rolling out 1.5.1"
|
||||
```
|
||||
|
||||
**MCP `claude/channel` notification gains `topic`** as an attribute so peers know which conversation an inbound message belongs to.
|
||||
|
||||
**Effort breakdown:** schema + drizzle migration + CLI verbs + broker routing changes (filter by topic membership) + skill update. ~250 LoC across CLI + ~200 LoC broker.
|
||||
|
||||
---
|
||||
|
||||
### 2. Humans in the mesh
|
||||
|
||||
**Mental model:** a human is a peer with `peer_type: "human"` whose presence is durable (no session pubkey rotation; identity tied to an account). They join *topics*, not the whole mesh — so they only see relevant traffic.
|
||||
|
||||
> **Implementation update (2026-05-02):** `peer_type: "ai" | "human" | "connector"` is already plumbed end-to-end in the broker (hello envelope, ConnectedPeer, list_peers). What was missing wasn't broker support — it's the **interface** for humans, who don't have browser-side ed25519 to do hello-sig. Realistic path: **REST API is the human interface** (rolled into #3 below). The web chat panel becomes a thin client that posts/reads via REST using the dashboard user's session auth — not its own keypair. This collapses #2 and #3 into a single deliverable: REST → UI on top.
|
||||
|
||||
**Wire:**
|
||||
|
||||
```jsonc
|
||||
// hello envelope gains:
|
||||
{
|
||||
"peer_type": "human",
|
||||
"session_pubkey": <ephemeral, per browser tab>,
|
||||
"member_pubkey": <durable, account-tied>,
|
||||
"display_name": "Alejandro"
|
||||
}
|
||||
```
|
||||
|
||||
**Web panel (`apps/web`):**
|
||||
|
||||
```
|
||||
/dashboard/mesh/<slug>/topic/<topic-name>
|
||||
├── topic header (members, settings)
|
||||
├── message stream (WS-driven, infinite scroll on history)
|
||||
├── compose box (typing indicator broadcast on focus)
|
||||
└── members sidebar (presence, profile, last_read_at)
|
||||
```
|
||||
|
||||
**Backend changes:**
|
||||
- Persistent message history per topic (drizzle table `topic_messages`; existing direct messages stay ephemeral by design).
|
||||
- Topic-scoped read receipts (`topic_member.last_read_at`).
|
||||
- Typing indicator: short-lived broadcast on the topic channel (`{type: "typing", peer: "..."}`).
|
||||
|
||||
**Privacy invariant:** a human in `#deploys` sees only `#deploys` traffic + DMs sent to them. Never the whole mesh. This is the *whole reason* topics come first.
|
||||
|
||||
**Effort:** WS endpoint already exists (broker side). Add: topic_messages table, history endpoint, web UI components (compose, stream, members). ~3 days.
|
||||
|
||||
---
|
||||
|
||||
### 3. REST API + external WS
|
||||
|
||||
**Auth:** API keys per mesh, scoped by capability + topic.
|
||||
|
||||
```yaml
|
||||
api_key:
|
||||
id: <ulid>
|
||||
mesh_slug: openclaw
|
||||
label: "ci-bot"
|
||||
hash: <argon2id>
|
||||
capabilities: ["send", "read"]
|
||||
topic_scopes: ["#deploys"] # null = all topics; explicit = whitelist
|
||||
created_at: <ts>
|
||||
last_used_at: <ts>
|
||||
revoked_at: <ts | null>
|
||||
```
|
||||
|
||||
**CLI for issuance (admin only):**
|
||||
|
||||
```bash
|
||||
claudemesh apikey create --label "ci-bot" --topic deploys --cap send,read
|
||||
claudemesh apikey list
|
||||
claudemesh apikey revoke <id>
|
||||
```
|
||||
|
||||
**REST endpoints (claudemesh.com/api/v1):**
|
||||
|
||||
```
|
||||
POST /v1/messages Send a message (auth: api key).
|
||||
GET /v1/topics/:name/messages History (with pagination cursor).
|
||||
GET /v1/peers List online peers (filtered by key scope).
|
||||
GET /v1/state Read mesh state.
|
||||
POST /v1/state Write mesh state.
|
||||
```
|
||||
|
||||
**External WS:** `wss://ic.claudemesh.com/ws?api_key=...&topic=deploys` — connects with `peer_type: "external"`. Push-pipe parity with internal sessions; can subscribe to topic streams.
|
||||
|
||||
**Why REST keys not session keypairs:** external clients (Zapier, GitHub Actions, mobile apps, Slack workspace bots) need long-lived bearer-like creds, not ephemeral keypairs. Different threat model — scope tightly via topic + capability.
|
||||
|
||||
**Effort:** ~3 days. Mostly broker work; CLI gets the issuance verbs.
|
||||
|
||||
---
|
||||
|
||||
### 4. Bridge peer
|
||||
|
||||
**Mental model:** a bridge is a peer that holds memberships in two meshes and forwards traffic on a single topic between them. SDK-only (no broker changes).
|
||||
|
||||
**Implementation (uses existing `@claudemesh/sdk`):**
|
||||
|
||||
```typescript
|
||||
import { Bridge } from "@claudemesh/sdk";
|
||||
|
||||
const bridge = new Bridge({
|
||||
meshes: ["work", "external"],
|
||||
topic: "incidents",
|
||||
filter: (msg) => !msg.tags.includes("internal-only"),
|
||||
loop_prevention: { tag: "via-bridge", max_hops: 2 },
|
||||
});
|
||||
await bridge.start();
|
||||
```
|
||||
|
||||
**Loop prevention:** every forwarded message gets a `bridge_hop_<n>` tag; bridges drop messages that already carry their own tag (prevents echo) and any message with `max_hops` exceeded.
|
||||
|
||||
**CLI:** `claudemesh bridge run <config.yaml>` — runs an SDK bridge as a long-lived process. Useful for "run a bridge inside a docker container or systemd unit."
|
||||
|
||||
**What it deliberately doesn't do:**
|
||||
- Cross-broker federation (that's a separate broker-to-broker protocol).
|
||||
- Bidirectional state/memory sync (only messages on a single topic).
|
||||
- Identity unification (a peer in mesh A is *not* the same peer in mesh B; the bridge appears as the messenger).
|
||||
|
||||
**Effort:** ~1 day on top of the existing SDK.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance signals
|
||||
|
||||
v0.2.0 ships when all four are demonstrable end-to-end:
|
||||
|
||||
1. A peer creates `#deploys`, two other peers join it, traffic is topic-scoped, mesh-wide chat doesn't see it.
|
||||
2. A human signs in at `claudemesh.com`, joins `#deploys`, sends a message, a Claude session in the mesh receives it as a `<channel>` interrupt with `topic="deploys"`.
|
||||
3. A `curl` POST against `/v1/messages` with an API key delivers a message into `#deploys`; the same API key is rejected on `#secrets`.
|
||||
4. A bridge peer running locally forwards `#incidents` between two test meshes; loop is prevented; one-shot demo recorded.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (explicitly)
|
||||
|
||||
- Topic hierarchy / nesting (flat namespace per mesh; revisit at scale).
|
||||
- Topic-scoped capability grants (`grant <peer> read:#topic`) — solvable later via capability extension.
|
||||
- Threads-within-topics (Slack-style). Defer.
|
||||
- Voice / video / file-upload UX for humans — text only in v0.2.0.
|
||||
- Federation, sandboxes, sim-sdk — explicitly deferred above.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- **Topics retrofit risk** — existing 1.5.0 message envelope assumes "to" is peer/group/star. Adding `topic` is additive on the wire but changes routing logic. Test path: backfill existing meshes with a default `#general` topic; opt-in to topic-only routing.
|
||||
- **Web chat session lifecycle** — humans expect "I closed the tab and came back, my place is preserved." Ephemeral session pubkeys break that. Workaround: tie human peer identity to `member_pubkey` + last_read_at on the topic; session pubkey rotates per tab but membership is durable.
|
||||
- **API key abuse** — leaked keys = anyone can post. Mitigations: capability + topic scoping; rate limits per key; `last_used_at` + audit trail; revoke verb is fast.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Do existing `@group` semantics survive intact, or do we collapse `@group` and `#topic` into one primitive? (Answer favored: keep both — different axes.)
|
||||
2. Should topics persist messages by default, or be opt-in? (Default: yes for `peer_type: "human"`-touched topics; configurable per topic for agent-only ones.)
|
||||
3. Where does mesh-MCP discovery live in the topic model — per topic or per mesh? (Likely per mesh; mesh-MCP is infrastructure, not conversation.)
|
||||
204
.artifacts/specs/2026-05-02-workspace-view.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Workspace view — per-user superset over joined meshes
|
||||
|
||||
**Status:** spec / not started
|
||||
**Target:** v0.4.0
|
||||
**Author:** Alejandro
|
||||
**Date:** 2026-05-02
|
||||
|
||||
## Why
|
||||
|
||||
Users routinely belong to multiple meshes — work, personal, side
|
||||
projects, ECIJA + flexicar + openclaw + prueba1 in our own dogfood.
|
||||
Today's CLI is mesh-scoped: every read or write either auto-picks the
|
||||
default mesh or forces an interactive picker. Common questions like
|
||||
*"who's online across all my meshes?"* or *"any new @-mentions
|
||||
anywhere?"* require N round-trips, one per mesh.
|
||||
|
||||
A few verbs already aggregate implicitly (`peer list`, `inbox`,
|
||||
`list`), but the surface is patchy and inconsistent.
|
||||
|
||||
We want the equivalent of "all my Slacks in one sidebar" — without
|
||||
breaking the per-mesh trust model that v0.3.0 was built around.
|
||||
|
||||
## What it is NOT
|
||||
|
||||
- **Not a literal universal mesh.** A single global mesh everyone
|
||||
joins collapses the trust boundary, blows up broadcast fan-out
|
||||
(O(users²)), and turns into spam. See the universal-mesh discussion
|
||||
rejected in this same session.
|
||||
- **Not federation.** Federation is the broker-side equivalent
|
||||
(already roadmapped under v0.3.0). Workspace is purely client-side.
|
||||
- **Not identity stitching for *other* peers.** `Mou@openclaw` and
|
||||
`Mou@flexicar-2` may or may not be the same human. Don't auto-merge.
|
||||
Stitching MY identities is fine — local config knows.
|
||||
|
||||
## What it IS
|
||||
|
||||
A virtual layer that aggregates reads across the meshes the user has
|
||||
joined, while keeping writes mesh-scoped. Pure projection over
|
||||
existing per-mesh tables. Zero broker changes. Zero protocol changes.
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ workspace │
|
||||
│ (per-user view, client) │
|
||||
└─┬────────┬────────┬─────────┬┘
|
||||
│ │ │ │
|
||||
┌─────▼──┐ ┌───▼──┐ ┌───▼──┐ ┌────▼──┐
|
||||
│ mesh A │ │ B │ │ C │ │ ... │
|
||||
└────────┘ └──────┘ └──────┘ └───────┘
|
||||
(each remains its own crypto + trust domain)
|
||||
```
|
||||
|
||||
## Surface
|
||||
|
||||
### New verbs (all read-only, all aggregating)
|
||||
|
||||
```bash
|
||||
claudemesh me # overview: meshes, online peers, unread, tasks
|
||||
claudemesh me topics # all subscribed topics, namespaced
|
||||
claudemesh me notifications # cross-mesh @-mentions feed
|
||||
claudemesh me activity # cross-mesh recent send/recv/topic-post
|
||||
claudemesh me search "<q>" # full-text across memory + topics + tasks
|
||||
```
|
||||
|
||||
`claudemesh me` (no subcommand) prints a one-screen dashboard:
|
||||
|
||||
```
|
||||
workspace — agutmou (4 meshes · 23 peers visible · 2 unread @you)
|
||||
|
||||
meshes
|
||||
openclaw 7 peers · 3 topics · last activity 2m
|
||||
flexicar-2 5 peers · 1 topic · last activity 18m
|
||||
prueba1 4 peers · idle
|
||||
ECIJA 7 peers · 2 topics · 1 @you · last activity 4h
|
||||
|
||||
unread @-mentions
|
||||
ECIJA · #incident-2026-05-02 · 1 from coronel-abos
|
||||
openclaw · #deploys · 1 from claudemesh-2
|
||||
|
||||
pending tasks (3)
|
||||
ECIJA ship-F4-cliente high claimed by you
|
||||
...
|
||||
```
|
||||
|
||||
### Default-aggregation rule for existing verbs
|
||||
|
||||
When `--mesh` is omitted on a *read-only* verb, aggregate. When
|
||||
`--mesh` is omitted on a *write* verb, fall back to current behavior
|
||||
(default mesh or interactive picker). Already-aggregating verbs keep
|
||||
working unchanged.
|
||||
|
||||
| Verb | Today | After workspace |
|
||||
|---|---|---|
|
||||
| `peer list` | aggregates ✅ | unchanged |
|
||||
| `inbox` | aggregates ✅ | unchanged |
|
||||
| `list` | aggregates ✅ (lists meshes) | unchanged |
|
||||
| `notification list` | mesh-scoped | aggregates by default |
|
||||
| `topic list` | mesh-scoped | aggregates with namespacing |
|
||||
| `task list` | mesh-scoped | aggregates by default |
|
||||
| `state list` | mesh-scoped | aggregates by default |
|
||||
| `memory recall` | mesh-scoped | aggregates by default |
|
||||
| `info` / `stats` / `ping` | mesh-scoped | unchanged (per-mesh diagnostics) |
|
||||
| `send`, `topic post`, `state set`, `remember`, ... | mesh-scoped | unchanged (writes pick a mesh) |
|
||||
|
||||
### Rendering rules for aggregated views
|
||||
|
||||
1. **Topic namespacing.** `#deploys` exists in two meshes — they're
|
||||
different rooms. Render as `openclaw/#deploys`. Inside a
|
||||
mesh-scoped command, keep the bare `#deploys` shorthand.
|
||||
2. **Peer name collisions.** `Mou@openclaw` notation when the same
|
||||
display name resolves in more than one mesh. Single resolution =
|
||||
bare name.
|
||||
3. **Time-grouped activity.** `me activity` sorts globally by ts
|
||||
descending; mesh tag is shown as a dim suffix.
|
||||
4. **Unread roll-up.** `me notifications` is a per-row
|
||||
`[mesh][topic][snippet]` list, newest first.
|
||||
|
||||
## API surface (REST)
|
||||
|
||||
Mirror the read aggregations server-side so the dashboard + future
|
||||
mobile/web UIs share the same endpoints.
|
||||
|
||||
```
|
||||
GET /v1/me # workspace overview
|
||||
GET /v1/me/meshes # joined meshes + summary stats
|
||||
GET /v1/me/topics # all subscribed topics, all meshes
|
||||
GET /v1/me/notifications # cross-mesh @-mentions
|
||||
GET /v1/me/activity # unified activity feed
|
||||
GET /v1/me/peers # already implicit; formalize
|
||||
GET /v1/me/search?q=... # full-text across tables
|
||||
```
|
||||
|
||||
Auth: needs a *user-scoped* api key (one issued per user, sees all
|
||||
their meshes), which we don't have today — current keys are mesh-
|
||||
scoped. Two options:
|
||||
|
||||
- **(a) Per-user key.** New token type `cm_u_...` issued by the
|
||||
dashboard, scopes to all meshes the issuing user belongs to. Cheaper
|
||||
to build; harder to reason about because the blast radius is
|
||||
larger if leaked.
|
||||
- **(b) Multi-mesh aggregation.** Accept N mesh-scoped keys
|
||||
concurrently; CLI auto-mints them via the existing `withRestKey`
|
||||
pattern, one per joined mesh. No new key type. More round-trips on
|
||||
cold start, but rotation/revocation stays simple.
|
||||
|
||||
**Recommendation: (b).** Reuses today's auth model, doesn't widen the
|
||||
blast radius, and the ephemeral keys we already mint per-command keep
|
||||
the surface area minimal. The CLI orchestrates the fan-out client-
|
||||
side.
|
||||
|
||||
## Storage
|
||||
|
||||
Pure projection at first. The cross-mesh queries are SELECT joins
|
||||
over `mesh_member`, `mesh_topic`, `mesh_topic_member`,
|
||||
`mesh_notification`, `mesh_topic_message`, `mesh_task`, `presence`.
|
||||
|
||||
If `me` queries become hot (likely once dashboards land), add a
|
||||
materialized `user_workspace_view` refreshed on writes. Don't
|
||||
optimize early.
|
||||
|
||||
## Effort
|
||||
|
||||
| Component | Effort |
|
||||
|---|---|
|
||||
| CLI verbs (`me`, `me topics`, etc.) | 1.5 days |
|
||||
| Default-aggregation rule across existing verbs | 0.5 day |
|
||||
| REST endpoints `/v1/me/*` | 1 day |
|
||||
| Multi-mesh apikey orchestration in `withRestKey` | 0.5 day |
|
||||
| Tests + docs | 0.5 day |
|
||||
| **Total** | **~4 days** |
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **`me` as namespace vs. flag.** Could be `claudemesh --workspace
|
||||
topics` instead of `claudemesh me topics`. The verb form is
|
||||
shorter and reads better; sticking with it.
|
||||
2. **Notification ordering.** All notifications globally interleaved
|
||||
by ts, or per-mesh sections? Default to **interleaved** with mesh
|
||||
tag prefix; users can `--by-mesh` to group.
|
||||
3. **Search relevance.** Cross-mesh full-text search is easy when each
|
||||
mesh has its own pg full-text index. Cross-mesh ranking is the
|
||||
harder problem (IDF varies). Punt to v0.4.1 — start with simple
|
||||
tied-rank merge.
|
||||
4. **Web dashboard.** Should the web dashboard's main view become a
|
||||
workspace view by default? Yes, but that's downstream of this
|
||||
spec — once `/v1/me/*` exists, the web rewrite is the obvious
|
||||
next step.
|
||||
|
||||
## Out of scope (v0.4.0)
|
||||
|
||||
- Federation / cross-broker workspace.
|
||||
- Identity stitching for non-self peers.
|
||||
- Cross-mesh search ranking sophistication.
|
||||
- Cross-mesh write fan-out (`me broadcast` is intentionally NOT a
|
||||
verb — too easy to misuse).
|
||||
- Mobile/web parity beyond the REST endpoints.
|
||||
|
||||
## Why we ship this
|
||||
|
||||
Because "I want one Slack-like sidebar for all my claudemesh meshes"
|
||||
is the highest-frequency UX gap users hit, and the answer is two
|
||||
days of plumbing on top of what already exists. Federation is the
|
||||
right answer for cross-organization reach; workspace is the right
|
||||
answer for *one user, many meshes*. Both compose.
|
||||
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,18 @@ 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 \
|
||||
# Liveness (Docker HEALTHCHECK) hits /health — permissive, tolerates
|
||||
# transient DB blips so the container isn't killed during brief DB
|
||||
# restarts. Deploy-time readiness is a separate /health/ready endpoint
|
||||
# which checks DB + migration version; an external gate should poll
|
||||
# that after container start and fail the deploy if not green.
|
||||
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:",
|
||||
|
||||
101
apps/broker/scripts/backfill-owner-members.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* One-shot backfill: every active mesh whose owner has no peer-identity
|
||||
* member row gets one minted via a fresh ed25519 keypair. Without this,
|
||||
* web-first owners (who never connected via CLI) can't access the chat
|
||||
* surface — issueDashboardApiKey is a FK to mesh.member, and the topic
|
||||
* page server component's owner branch picks the oldest member row in
|
||||
* the mesh (which is null if none exist).
|
||||
*
|
||||
* Idempotent. Safe to re-run. Each run prints per-mesh status.
|
||||
*
|
||||
* Owner identification: a member is the "owner's row" when its user_id
|
||||
* matches mesh.owner_user_id. The script targets meshes that have zero
|
||||
* such matching rows (regardless of total member count — a mesh with
|
||||
* peers but no owner member also gets a fresh owner row).
|
||||
*
|
||||
* The owner row is also auto-subscribed to #general as 'lead' so the
|
||||
* unread/role accounting matches CLI-flow meshes.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=... bun apps/broker/scripts/backfill-owner-members.ts
|
||||
*/
|
||||
|
||||
import postgres from "postgres";
|
||||
import sodium from "libsodium-wrappers";
|
||||
|
||||
interface Orphan {
|
||||
meshId: string;
|
||||
slug: string;
|
||||
ownerUserId: string;
|
||||
meshName: string;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL not set");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
await sodium.ready;
|
||||
|
||||
const sql = postgres(url, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const orphans = await sql<Orphan[]>`
|
||||
SELECT m.id AS "meshId", m.slug, m.owner_user_id AS "ownerUserId", m.name AS "meshName"
|
||||
FROM mesh.mesh m
|
||||
WHERE m.archived_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM mesh.member mm
|
||||
WHERE mm.mesh_id = m.id
|
||||
AND mm.revoked_at IS NULL
|
||||
AND mm.user_id = m.owner_user_id
|
||||
)
|
||||
ORDER BY m.created_at
|
||||
`;
|
||||
console.log(`backfill · ${orphans.length} meshes need an owner member row`);
|
||||
|
||||
let inserted = 0;
|
||||
for (const o of orphans) {
|
||||
const kp = sodium.crypto_sign_keypair();
|
||||
const peerPubkey = sodium.to_hex(kp.publicKey);
|
||||
const id = sodium.to_hex(sodium.randombytes_buf(16));
|
||||
try {
|
||||
await sql.begin(async (tx) => {
|
||||
await tx`
|
||||
INSERT INTO mesh.member (
|
||||
id, mesh_id, peer_pubkey, display_name, role,
|
||||
user_id, dashboard_user_id
|
||||
)
|
||||
VALUES (
|
||||
${id}, ${o.meshId}, ${peerPubkey},
|
||||
${o.meshName + "-owner"}, ${"admin"}::mesh.role,
|
||||
${o.ownerUserId}, ${o.ownerUserId}
|
||||
)
|
||||
`;
|
||||
// Subscribe to #general as 'lead' if the topic exists.
|
||||
await tx`
|
||||
INSERT INTO mesh.topic_member (topic_id, member_id, role)
|
||||
SELECT t.id, ${id}, ${"lead"}::mesh.topic_member_role
|
||||
FROM mesh.topic t
|
||||
WHERE t.mesh_id = ${o.meshId} AND t.name = 'general'
|
||||
ON CONFLICT (topic_id, member_id) DO NOTHING
|
||||
`;
|
||||
});
|
||||
inserted += 1;
|
||||
console.log(` + ${o.slug.padEnd(20)} owner=${o.ownerUserId.slice(0, 8)}… member=${id.slice(0, 8)}… pk=${peerPubkey.slice(0, 12)}…`);
|
||||
} catch (e) {
|
||||
console.error(` ✗ ${o.slug}: ${(e as Error).message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
console.log(`backfill done · ${inserted} owner member rows inserted`);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("backfill failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
87
apps/broker/scripts/bootstrap-cmh-migrations.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* One-shot bootstrap for the new mesh.__cmh_migrations tracking table.
|
||||
*
|
||||
* Run this against an EXISTING prod DB exactly once before deploying
|
||||
* the new runtime migrator. It:
|
||||
* 1. Creates mesh.__cmh_migrations if it doesn't exist
|
||||
* 2. Hashes every .sql file in packages/db/migrations
|
||||
* 3. Inserts a row per file (filename + sha256) with applied_at = NOW()
|
||||
* 4. ON CONFLICT (filename) DO NOTHING — safe to re-run
|
||||
*
|
||||
* The script does NOT execute any migration SQL — it only seeds the
|
||||
* tracking table to reflect the schema state that was previously
|
||||
* applied by drizzle (or by hand). After this runs, the broker's
|
||||
* startup migrator will treat 0000..N as already-applied and only
|
||||
* apply truly new files going forward.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=... bun apps/broker/scripts/bootstrap-cmh-migrations.ts
|
||||
*
|
||||
* Safe to run multiple times. Output prints per-file status.
|
||||
*/
|
||||
|
||||
import postgres from "postgres";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL not set");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
join(process.cwd(), "..", "..", "packages", "db", "migrations"),
|
||||
join(process.cwd(), "packages", "db", "migrations"),
|
||||
"/app/migrations",
|
||||
];
|
||||
const folder = candidates.find((p) => existsSync(p));
|
||||
if (!folder) {
|
||||
console.error("migrations folder not found");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const files = readdirSync(folder).filter((f) => f.endsWith(".sql")).sort();
|
||||
console.log(`bootstrap · ${files.length} files at ${folder}`);
|
||||
|
||||
const sql = postgres(url, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
await sql.unsafe(`
|
||||
CREATE SCHEMA IF NOT EXISTS mesh;
|
||||
CREATE TABLE IF NOT EXISTS mesh.__cmh_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
sha256 TEXT NOT NULL,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
for (const f of files) {
|
||||
const content = readFileSync(join(folder, f), "utf8");
|
||||
const sha = createHash("sha256").update(content).digest("hex");
|
||||
const result = await sql`
|
||||
INSERT INTO mesh.__cmh_migrations (filename, sha256)
|
||||
VALUES (${f}, ${sha})
|
||||
ON CONFLICT (filename) DO NOTHING
|
||||
RETURNING filename
|
||||
`;
|
||||
if (result.length > 0) {
|
||||
inserted += 1;
|
||||
console.log(` + ${f} ${sha.slice(0, 12)}…`);
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
console.log(`bootstrap done · ${inserted} inserted, ${skipped} already tracked`);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("bootstrap failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
263
apps/broker/src/audit.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deterministic JSON serialization: keys sorted recursively. The store
|
||||
* is JSONB, which does NOT preserve key order, so hashing a naive
|
||||
* JSON.stringify(row.payload) on verify can yield a different string
|
||||
* from insert-time — false tamper reports. Canonical form guarantees
|
||||
* both sides agree.
|
||||
*/
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return "[" + value.map(canonicalJson).join(",") + "]";
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return (
|
||||
"{" +
|
||||
keys
|
||||
.map((k) => JSON.stringify(k) + ":" + canonicalJson(obj[k]))
|
||||
.join(",") +
|
||||
"}"
|
||||
);
|
||||
}
|
||||
|
||||
function computeHash(
|
||||
prevHash: string,
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
createdAt: Date,
|
||||
): string {
|
||||
const input = `${prevHash}|${meshId}|${eventType}|${actorMemberId}|${canonicalJson(payload)}|${createdAt.toISOString()}`;
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable 63-bit lock key per mesh for audit serialization under HA.
|
||||
* Use the audit lock space; keep distinct from migrate's 74737_73831.
|
||||
*/
|
||||
function meshLockKey(meshId: string): bigint {
|
||||
const digest = createHash("sha256").update("audit:" + meshId).digest();
|
||||
const unsigned = digest.readBigUInt64BE(0);
|
||||
return unsigned & 0x7fffffffffffffffn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Concurrency under HA: wraps the write in a transaction that takes
|
||||
* `pg_advisory_xact_lock(meshLockKey(meshId))` before reading the
|
||||
* tail hash from the DB. This serializes all concurrent writers to
|
||||
* the same mesh and prevents the chain from forking. The in-memory
|
||||
* `lastHash` cache is updated after a successful commit.
|
||||
*/
|
||||
export async function audit(
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
actorDisplayName: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const createdAt = new Date();
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const key = meshLockKey(meshId);
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(${key}::bigint)`);
|
||||
const [latest] = await tx
|
||||
.select({ hash: auditLog.hash })
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.meshId, meshId))
|
||||
.orderBy(desc(auditLog.id))
|
||||
.limit(1);
|
||||
const prevHash = latest?.hash ?? "genesis";
|
||||
const hash = computeHash(prevHash, meshId, eventType, actorMemberId, payload, createdAt);
|
||||
await tx.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),
|
||||
});
|
||||
}
|
||||
}
|
||||
82
apps/broker/src/broker-crypto.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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 && /^[0-9a-f]{64}$/i.test(env.BROKER_ENCRYPTION_KEY)) {
|
||||
_key = Buffer.from(env.BROKER_ENCRYPTION_KEY, "hex");
|
||||
return _key;
|
||||
}
|
||||
|
||||
// In production, refuse to start without a persistent key. Silently
|
||||
// generating a random one meant every restart invalidated all encrypted
|
||||
// rows on disk — and the ephemeral key was logged in clear, which is
|
||||
// itself a leak.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
log.error("BROKER_ENCRYPTION_KEY is missing or malformed (need 64 hex chars) — refusing to start in production");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Dev only: generate a stable per-process key. Never log the value.
|
||||
_key = randomBytes(32);
|
||||
log.warn("BROKER_ENCRYPTION_KEY not set — using ephemeral key for this dev process (encrypted data WILL NOT survive restarts). Set BROKER_ENCRYPTION_KEY to a 64-hex-char value for persistence.");
|
||||
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 (e) {
|
||||
// Loud failure: if a stored row fails to decrypt the key changed or
|
||||
// data is corrupt — don't silently return null and let downstream
|
||||
// code assume "no value".
|
||||
log.error("decryptFromStorage failed", { err: e instanceof Error ? e.message : String(e) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
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, meshTopic, meshTopicMember } 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,211 @@ 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" } };
|
||||
}
|
||||
|
||||
// 6b. Auto-subscribe the new member to #general (the default mesh-wide
|
||||
// room). Idempotent via unique (topic_id, member_id). If the mesh was
|
||||
// created before #general auto-creation existed, ensure it now via a
|
||||
// best-effort INSERT … ON CONFLICT — backfill migration handles the
|
||||
// bulk case so this is just a safety net.
|
||||
await db
|
||||
.insert(meshTopic)
|
||||
.values({
|
||||
meshId: inv.meshId,
|
||||
name: "general",
|
||||
description: "Default mesh-wide channel. Every member can read and post.",
|
||||
visibility: "public",
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
const [generalTopic] = await db
|
||||
.select({ id: meshTopic.id })
|
||||
.from(meshTopic)
|
||||
.where(and(eq(meshTopic.meshId, inv.meshId), eq(meshTopic.name, "general")))
|
||||
.limit(1);
|
||||
if (generalTopic) {
|
||||
await db
|
||||
.insert(meshTopicMember)
|
||||
.values({ topicId: generalTopic.id, memberId: row.id, role: "member" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||