Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ spa/node_modules/
# Cache / state from auxiliary tools
.claude-flow/
dist/*.apk

# Pre-built release binaries served by the landing site (large, regenerated locally)
clients/website/public/downloads/
clients/website/dist/downloads/
clients/website/dist.zip
65 changes: 59 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ jot is a universal encrypted note system — think digital post-its. Designed to
feel like a Unix tool: simple, composable, pipeable.

- No email, no password — identity is a local UUID with a friendly name
- Two binaries: `jot` (CLI client) and `jot-server` (API + MCP server)
- Runs anywhere: Linux (musl), macOS, Windows
- Three binaries: `jot` (CLI/TUI), `jot-server` (API + embedded SPA + MCP HTTP endpoint), and `jot-mcp` (stdio MCP bridge for Claude Desktop/Code)
- OAuth 2.1 + PKCE + Dynamic Client Registration — same token shared by CLI, SPA, mobile, and the MCP bridge
- Runs anywhere: Linux (musl), macOS, Windows; Android client (Flutter) in beta
- Multi-user: open registration or invite-token gating
- Board and note sharing with per-identity access control

Expand Down Expand Up @@ -53,18 +54,30 @@ jot has no email or password. Identity is generated automatically on first
`jot-server serve` — a UUID and a cryptographic key pair stored in
`~/.local/share/jot/`.

Clients authenticate via **OAuth 2.1 Authorization Code + PKCE** with Dynamic
Client Registration (RFC 7591). The CLI, SPA, Android app, and `jot-mcp` stdio
bridge all share the same `tokens.json` and auto-refresh in the background.

**First device** — identity is created automatically when you start the server:
```bash
jot-server serve # generates identity, registers a local device, prints token
```

Then log a CLI/MCP client in via the browser:
```bash
jot login # opens browser → OAuth consent → tokens persisted locally
```

**Linking a new device** — use the web UI (Profile → "Link a new device") or:
```bash
# On the new device, open the URL shown in the browser
# The web UI displays a QR code and a jot link <token> command to run
# The web UI displays a QR code, a 4-digit pairing code and a jot link <token> command
jot link <token>
```

> Legacy device-JWT auth (pre-OAuth) is still accepted when `jot-server` is
> started with `--legacy-device-tokens`; it will be removed in a future release.

**Registration modes:**

```bash
Expand Down Expand Up @@ -206,6 +219,41 @@ No separate web server needed.
- Profile / devices pages : set/generate friendly name, link new devices, manage invites
- Data export to JSON (plain or AES-256-GCM encrypted with PBKDF2)

## MCP server (Claude Desktop / Claude Code / Cursor)

`jot-server` exposes an MCP endpoint at `POST /mcp` using the Streamable HTTP
transport. For LLM clients that only support stdio, a thin bridge binary
`jot-mcp` proxies stdio ↔ the HTTP endpoint and reuses the same OAuth tokens
as the CLI.

```jsonc
// ~/.config/Claude/claude_desktop_config.json
{
"mcpServers": {
"jot": { "command": "jot-mcp" }
}
}
```

Tools exposed: `list_boards`, `get_board`, `list_notes`, `read_note`, `add_note`,
`list_journal`, `list_todo`, `move_block`, … (see `jot-mcp --list-tools`).
`search` is currently stubbed pending a server-side decision.

## Mobile (Android, beta)

A Flutter client lives under `mobile/`. Highlights:

- OAuth login via `flutter_appauth` + DCR, auto-refresh
- Offline-first cache for boards/notes (`shared_preferences` + on-disk blob cache)
- Connection monitor with online/offline badge + graceful fallback
- Voice notes — encrypted AAC/m4a recorded in-app, played back inline
- Device linking via QR code, URL, 4-digit pairing code, or raw token

Build & deploy to the local emulator:
```bash
scripts/mobile-emulator.sh up && scripts/mobile-emulator.sh deploy
```

## TUI

`jot tui` opens a Ratatui interface with keyboard navigation:
Expand All @@ -227,8 +275,10 @@ aggregations (Journal / Todo / Graph) are CLI- or SPA-only.

| Command | Description |
|---|---|
| `jot-server serve [--bind ADDR] [--open-registration]` | Start the HTTP + MCP server |
| `jot-server serve [--bind ADDR] [--open-registration] [--legacy-device-tokens]` | Start the HTTP + MCP server |
| `jot-server openapi` | Dump the OpenAPI spec to stdout |
| `jot-mcp` | Stdio MCP bridge (spawned by Claude Desktop/Code) |
| `jot login` | OAuth Auth Code + PKCE login flow (opens browser) |
| `jot migrate` | Apply pending DB migrations without starting the server |
| `jot tui` | Launch the interactive TUI |
| `jot export [--out file]` | Export all your data as JSON |
Expand Down Expand Up @@ -321,14 +371,17 @@ jot migrate

| Component | Technology |
|---|---|
| Language | Rust (edition 2021) |
| Language | Rust (edition 2021), virtual workspace `shared/` + `server/` + `clients/` |
| HTTP framework | Axum 0.7 |
| Database | SQLite via `sqlx` 0.8 |
| Blob storage | Local filesystem (default) or S3-compatible |
| Cryptography | RustCrypto — X25519, AES-256-GCM, Ed25519, HKDF |
| Auth | OAuth 2.1 Auth Code + PKCE + DCR (RFC 7591) |
| MCP | Streamable HTTP (`/mcp`) + stdio bridge (`jot-mcp`) |
| CLI | Clap v4 + Ratatui TUI |
| Web frontend | Preact 10 + Vite 6 + `@preact/signals` |
| Web assets | `rust-embed` (SPA compiled into binary) |
| Web assets | `rust-embed` (SPA compiled into the server binary) |
| Mobile | Flutter (Android beta) + Rust FFI |

## Data export

Expand Down
28 changes: 28 additions & 0 deletions clients/web/src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,34 @@ export async function ensureClient(cfg: OauthConfig): Promise<string> {
return json.client_id;
}

/**
* Create a brand-new identity on the server and immediately receive an OAuth
* access + refresh token bound to this SPA's DCR client_id. Replaces the
* legacy `POST /register` → device-JWT path. Throws on 403 (invite required
* or registration closed) with the server's reason in the message.
*/
export async function signup(
cfg: OauthConfig,
inviteToken?: string,
): Promise<TokenSet> {
const clientId = await ensureClient(cfg);
const body: Record<string, string> = { client_id: clientId, scope: cfg.scope };
if (inviteToken) body.invite_token = inviteToken;
const resp = await fetch(`${cfg.serverUrl}/oauth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const { error: reason } = await resp.json().catch(() => ({ error: "unknown" }));
const err = new Error(reason ?? `signup failed: ${resp.status}`);
(err as Error & { status?: number; reason?: string }).status = resp.status;
(err as Error & { status?: number; reason?: string }).reason = reason;
throw err;
}
return resp.json() as Promise<TokenSet>;
}

/**
* Start the OAuth login flow: generate PKCE verifier + state, store them in
* sessionStorage, then redirect the browser to the authorization endpoint.
Expand Down
40 changes: 13 additions & 27 deletions clients/web/src/components/DeviceRegister.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { BookOpen, RefreshCw, Link, Hash } from "lucide-react";
import { getLinkByCode, getLinkStatus, registerPubkey } from "../api";
import { getLinkByCode, getLinkStatus, registerPubkey, oauthCfg } from "../api";
import { signup } from "../auth/oauth";
import { saveTokens } from "../auth/tokens";
import { QrCode } from "./QrCode";
import { t } from "../i18n";

Expand Down Expand Up @@ -42,39 +44,23 @@ export function DeviceRegister() {
async function attemptRegister(invite?: string) {
setInviteError(null);
try {
const body: Record<string, string> = {};
if (invite) body.invite_token = invite;

const r = await fetch(`${BASE}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});

if (r.ok) {
const { jwt } = await r.json();
localStorage.setItem("token", jwt);
await registerPubkey();
location.hash = "#/";
return;
}

const { error: reason } = await r.json().catch(() => ({ error: "unknown" }));

if (r.status === 403) {
if (reason === "invite_required") {
setMode(invite ? "invite_required" : "invite_required");
const tokens = await signup(oauthCfg, invite);
saveTokens(tokens);
await registerPubkey();
location.hash = "#/";
} catch (e) {
const err = e as Error & { status?: number; reason?: string };
if (err.status === 403) {
if (err.reason === "invite_required") {
setMode("invite_required");
if (invite) setInviteError("Invite token invalide ou révoqué.");
} else {
setMode("registration_closed");
}
} else {
setError(`Erreur: ${reason}`);
setError(`Erreur: ${err.message ?? String(e)}`);
setMode("invite_required");
}
} catch (e) {
setError(String(e));
setMode("invite_required");
}
}

Expand Down
10 changes: 9 additions & 1 deletion clients/web/src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,15 @@ let _rawPrivKeyCache: Uint8Array | null = null;
async function getKeyPair(): Promise<CryptoKeyPair> {
if (_keyPairCache) return _keyPairCache;

const tok = localStorage.getItem("token") ?? "";
const { ensureFresh } = await import("./auth/tokens");
const { oauthCfg } = await import("./api");
let tok = "";
try {
tok = await ensureFresh(oauthCfg);
} catch {
// Fallback for any session still pinned to the legacy device-JWT path.
tok = localStorage.getItem("token") ?? "";
}
const r = await fetch("/identity/me/privkey", {
headers: { Authorization: `Bearer ${tok}` },
});
Expand Down
18 changes: 17 additions & 1 deletion clients/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ import { defineConfig } from "vite";
import preact from "@preact/preset-vite";

export default defineConfig({
plugins: [preact()],
base: "/demo/",
// devToolsEnabled:false désactive transform-hook-names — incompatible avec
// zimmerframe@1.1.4 (CJS exports retirés), cf. erreur dev preset-vite@2.10.
plugins: [preact({ devToolsEnabled: false })],
build: { outDir: "dist", emptyOutDir: true },
// Dev derrière scripts/dev-proxy.mjs : HMR WS sur /demo/ pour que le proxy
// route correctement vers cette instance vite (cf. routeWs).
server: process.env.DEV_BEHIND_PROXY
? {
host: "127.0.0.1",
port: Number(process.env.SPA_PORT || 5173),
strictPort: true,
hmr: {
path: "/demo/",
clientPort: Number(process.env.PROXY_PORT || 3000),
},
}
: undefined,
});
Loading
Loading