Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ supabase/.temp/
# Claude Code: local-only, machine-specific (keep shared config committed)
**/.claude/settings.local.json
**/.claude/worktrees/

# qc package build output (scripts/build-qc.js)
packages/qryptchat/dist/
16 changes: 16 additions & 0 deletions bin/qc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env node
// qc: qrypt.chat in the terminal. `qc` opens the chat client; `qc --help` lists the rest.
import { readFileSync } from 'node:fs';
import { main } from '../src/cli/commands.js';

const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));

// main resolves when the command is done (the TUI quit, stdin closed under
// mcp); exit then, or the SSE socket and timers would hold the process open.
main(process.argv.slice(2), { version: pkg.version }).then(
() => process.exit(0),
(err) => {
process.stderr.write(`qc: ${err?.message ?? err}\n`);
process.exit(1);
},
);
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
"version": "0.0.3",
"type": "module",
"bin": {
"qryptchat": "./bin/qryptchat-cli.js",
"qc": "./bin/qc.js",
"qryptchat": "./bin/qc.js",
"qryptchat-agent": "./bin/qryptchat-agent.js"
},
"packageManager": "bun@1.4.0",
"scripts": {
"lint:oxlint": "oxlint",
"lint:oxlint:fix": "oxlint --fix",
"cli": "bun bin/qryptchat-cli.js",
"cli": "bun bin/qc.js",
"agent": "bun bin/qryptchat-agent.js",
"qc": "bun bin/qc.js",
"build:qc": "bun scripts/build-qc.js",
"dev": "bun --bun next dev",
"build": "bun --bun next build",
"start": "bun --bun next start",
Expand All @@ -31,6 +34,7 @@
"dependencies": "command -v pnpm && pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm || true"
},
"devDependencies": {
"@profullstack/hqtui": "^0.8.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.6.1",
Expand Down
80 changes: 80 additions & 0 deletions packages/qryptchat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# qc

[qrypt.chat](https://qrypt.chat) in your terminal: a full-screen, end-to-end encrypted chat client, a scriptable CLI and an MCP server, all in one command.

```sh
npm install -g @profullstack/qryptchat # or: bun add -g @profullstack/qryptchat
qc
```

The first run signs you in through your browser, then opens your chats.

## The client

```
🔒 qrypt.chat @you ● live
┌ Chats ──────────┐┌ alice ───────────────────────── 2 people · ML-KEM-1024 ┐
│▸ alice 2 ││ ───────────────────── Tue 6 Oct ───────────────────── │
│ team chat ││ alice 14:02 │
│ ops ││ shipped it 🚀 │
│ ││ you 14:03 │
│ ││ 🔥🔥 │
│ ││ alice is typing… │
│ ││ Message alice (Ctrl+E emoji, :rocket: works too) │
└─────────────────┘└────────────────────────────────────────────────────────┘
ENTER send CTRL+E emoji TAB chats PGUP scroll CTRL+C quit
```

| Key | Does |
|---|---|
| Enter | send (or open the highlighted chat) |
| Ctrl+E | the emoji picker: search, recents and every group, drawn with [OpenEmoji](https://github.com/profullstack/openemoji) |
| `:rocket:` | shortcodes turn into emoji when you send |
| Tab / Esc | move between the chat list and the composer |
| Ctrl+N / Ctrl+P | next / previous chat |
| PgUp / PgDn, wheel | scroll back through the conversation |
| Ctrl+R | reload |
| Ctrl+C | quit |

The mouse works too: click a chat, scroll the transcript, click an emoji. Unread counts, typing indicators and new messages arrive live.

## Scripts and agents

```sh
qc chats # id and name of every chat
qc read alice -n 20 # the last 20 messages, decrypted
qc send alice "on my way" # encrypted to every participant
echo "deploy done" | qc send ops -
qc listen --json # new messages as NDJSON, until you stop it
qc whoami
```

`<chat>` is a chat id or any unique part of its name. Add `--json` for machine output.

### MCP

```sh
qc mcp
```

runs an MCP server on stdio with three tools: `list_chats`, `read_chat` and `send_message`. Encryption and decryption happen on this machine; the server only ever sees ciphertext. For Claude Code:

```sh
claude mcp add qryptchat -- qc mcp
```

## Signing in

`qc login` uses OAuth 2.1 (authorization code + PKCE), started from the terminal:

1. qc opens `qrypt.chat/cli/authorize` with a one-time ML-KEM-1024 public key and prints a confirmation code.
2. You check the browser shows the same code, then approve.
3. The browser seals your keys to that one-time key, and qc receives them plus a session of its own. The server only relays ciphertext.

Over SSH, `qc login --oob` shows a code in the browser for you to paste instead of redirecting back.

The session and keys are kept in `~/.config/qc/session.json` (or `$QC_HOME`), mode 0600. Sessions refresh themselves; `qc logout` deletes them. Point at another server with `--url` or `QC_URL`.

## Licence

MIT. Emoji: OpenEmoji by Profullstack, Inc. (CC BY 4.0).
14 changes: 14 additions & 0 deletions packages/qryptchat/bin/qc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env node
// qc: qrypt.chat in the terminal. `qc` opens the chat client; `qc --help` lists the rest.
import { readFileSync } from 'node:fs';
import { main } from '../dist/commands.js';

const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));

main(process.argv.slice(2), { version: pkg.version }).then(
() => process.exit(0),
(err) => {
process.stderr.write(`qc: ${err?.message ?? err}\n`);
process.exit(1);
},
);
41 changes: 41 additions & 0 deletions packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@profullstack/qryptchat",
"version": "0.1.0",
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
"qryptchat": "./bin/qc.js"
},
"files": [
"bin",
"dist",
"README.md"
],
"engines": {
"node": ">=22"
},
"dependencies": {
"@noble/ciphers": "^2.0.0",
"@profullstack/hqtui": "^0.8.0",
"mlkem": "^2.5.0"
},
"keywords": [
"qrypt.chat",
"chat",
"e2ee",
"ml-kem",
"post-quantum",
"tui",
"cli",
"mcp"
],
"homepage": "https://qrypt.chat",
"repository": {
"type": "git",
"url": "git+https://github.com/profullstack/qryptchat-web.git",
"directory": "packages/qryptchat"
},
"license": "MIT",
"author": "Profullstack, Inc."
}
7 changes: 7 additions & 0 deletions public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ QryptChat uses NIST-approved post-quantum algorithms (ML-KEM-1024 / CRYSTALS-Kyb
- [Terms of Service](https://qrypt.chat/terms) — Usage terms
- [Warrant Canary](https://qrypt.chat/warrant-canary) — Transparency statement

## Terminal and agents

- [qc](https://www.npmjs.com/package/@profullstack/qryptchat) is the terminal client: `npm install -g @profullstack/qryptchat`, then run `qc`.
- It is a full-screen chat app for people, and `qc chats|read|send|listen` for scripts.
- `qc mcp` is an MCP server with the tools `list_chats`, `read_chat` and `send_message`.
- Encryption happens on the machine running qc.

## Company

- **Name**: Profullstack, Inc.
Expand Down
12 changes: 10 additions & 2 deletions public/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ Quantum-resistant, end-to-end encrypted messaging.

Phone number + SMS verification code. No passwords.

## API
## Terminal, scripts and agents: qc

No public API. Self-hostable via the open-source repo at https://github.com/profullstack/qryptchat-web
`npm install -g @profullstack/qryptchat` installs `qc`:

- `qc` opens a full-screen chat client (emoji picker on Ctrl+E).
- `qc chats`, `qc read <chat>`, `qc send <chat> <text>` and `qc listen --json` are for scripts.
- `qc mcp` runs an MCP server on stdio with the tools `list_chats`, `read_chat` and `send_message`.

`qc login` signs in through the browser (OAuth 2.1, authorization code + PKCE). The browser seals the account's keys to a one-time ML-KEM-1024 key that qc generated, so messages are encrypted and decrypted on the machine running qc. The server only ever sees ciphertext.

Self-hostable via the open-source repo at https://github.com/profullstack/qryptchat-web
31 changes: 31 additions & 0 deletions scripts/build-qc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bun
/**
* Build the publishable qc package: `bun scripts/build-qc.js`.
*
* Bundles src/cli (and the web app's own crypto it imports) into
* packages/qryptchat/dist, leaving the runtime dependencies external so npm
* installs them. The TUI and MCP server stay separate chunks, loaded only by
* the commands that need them.
*/
import { readFileSync, rmSync } from 'node:fs';
import { join, resolve } from 'node:path';

const root = resolve(import.meta.dir, '..');
const out = join(root, 'packages', 'qryptchat', 'dist');
const pkg = JSON.parse(readFileSync(join(root, 'packages', 'qryptchat', 'package.json'), 'utf8'));

rmSync(out, { recursive: true, force: true });
const result = await Bun.build({
entrypoints: [join(root, 'src/cli/commands.js'), join(root, 'src/cli/tui.js'), join(root, 'src/cli/mcp.js')],
outdir: out,
target: 'node',
format: 'esm',
splitting: true,
external: Object.keys(pkg.dependencies),
naming: { entry: '[name].js', chunk: 'chunk-[hash].js' },
});
if (!result.success) {
for (const log of result.logs) console.error(log);
process.exit(1);
}
console.log(result.outputs.map((o) => `${o.path.replace(`${root}/`, '')} ${(o.size / 1024).toFixed(0)} KB`).join('\n'));
36 changes: 36 additions & 0 deletions src/app/api/cli/authorize/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/middleware/auth.js';
import { CliAuthError, issueCode, serviceClient } from '@/lib/auth/cli-auth.js';

/**
* POST /api/cli/authorize: the signed-in web app approves a `qc login`.
* Body: { code_challenge, code_challenge_method: "S256", redirect_uri, client_name, key_blob }
* Returns { code }. See src/lib/auth/cli-auth.js for the whole flow.
*/
export const POST = withAuth(async ({ request, locals }) => {
let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid_request', error_description: 'Invalid JSON body' }, { status: 400 });
}
if (body?.code_challenge_method !== 'S256') {
return NextResponse.json({ error: 'invalid_request', error_description: 'Only S256 is supported' }, { status: 400 });
}
try {
const code = await issueCode(serviceClient(), {
authUserId: locals.user.id,
codeChallenge: body.code_challenge,
redirectUri: body.redirect_uri,
clientName: body.client_name,
keyBlob: body.key_blob
});
return NextResponse.json({ code }, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
if (error instanceof CliAuthError) {
return NextResponse.json({ error: error.code, error_description: error.message }, { status: error.status });
}
console.error('[cli/authorize]', error);
return NextResponse.json({ error: 'server_error' }, { status: 500 });
}
});
42 changes: 42 additions & 0 deletions src/app/api/cli/token/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { CliAuthError, redeemCode, refresh, serviceClient } from '@/lib/auth/cli-auth.js';

/**
* POST /api/cli/token: the qc CLI's token endpoint.
* grant_type=authorization_code { code, code_verifier, redirect_uri }
* grant_type=refresh_token { refresh_token }
* JSON or form-encoded. Unauthenticated by design: the code and the PKCE
* verifier are the credential.
*/
export async function POST(request) {
let body;
try {
const type = request.headers.get('content-type') || '';
body = type.includes('application/x-www-form-urlencoded')
? Object.fromEntries(new URLSearchParams(await request.text()))
: await request.json();
} catch {
return NextResponse.json({ error: 'invalid_request', error_description: 'Unreadable body' }, { status: 400 });
}
const headers = { 'Cache-Control': 'no-store', Pragma: 'no-cache' };
try {
if (body?.grant_type === 'authorization_code') {
const result = await redeemCode(serviceClient(), {
code: body.code,
codeVerifier: body.code_verifier,
redirectUri: body.redirect_uri
});
return NextResponse.json(result, { headers });
}
if (body?.grant_type === 'refresh_token') {
return NextResponse.json(await refresh(body.refresh_token), { headers });
}
return NextResponse.json({ error: 'unsupported_grant_type' }, { status: 400, headers });
} catch (error) {
if (error instanceof CliAuthError) {
return NextResponse.json({ error: error.code, error_description: error.message }, { status: error.status, headers });
}
console.error('[cli/token]', error);
return NextResponse.json({ error: 'server_error' }, { status: 500, headers });
}
}
13 changes: 6 additions & 7 deletions src/app/api/events/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { sseManager } from '@/lib/api/sse-manager.js';
import { createSupabaseServerClient } from '@/lib/supabase.js';
import { authenticateRequest } from '@/lib/api/middleware/auth.js';

/**
* GET handler for SSE connections
Expand All @@ -14,14 +14,13 @@ export async function GET(request, { params } = {}) {
console.log('📡 [SSE] New connection request');

try {
// Authenticate the user using getUser() for security
const supabase = await createSupabaseServerClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();

if (authError || !user) {
console.error('📡 [SSE] Authentication failed:', authError?.message || 'No user');
// Bearer token (the qc CLI) or the session cookie (the web app).
const auth = await authenticateRequest(request);
if (!auth.success) {
console.error('📡 [SSE] Authentication failed:', auth.error);
return new Response('Unauthorized', { status: 401 });
}
const { user, supabase } = auth;

const authUserId = user.id;
console.log(`📡 [SSE] Auth user ${authUserId} authenticated`);
Expand Down
Loading
Loading