happy-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/api/client.d.ts +39 -0
- package/dist/api/client.js +49 -0
- package/dist/auth/credentials.d.ts +22 -0
- package/dist/auth/credentials.js +80 -0
- package/dist/auth/crypto.d.ts +118 -0
- package/dist/auth/crypto.js +249 -0
- package/dist/auth/pairing.d.ts +16 -0
- package/dist/auth/pairing.js +90 -0
- package/dist/auth/refresh.d.ts +11 -0
- package/dist/auth/refresh.js +50 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +13 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +30 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +306 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.js +22 -0
- package/dist/relay/client.d.ts +34 -0
- package/dist/relay/client.js +242 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +89 -0
- package/dist/session/keys.d.ts +25 -0
- package/dist/session/keys.js +41 -0
- package/dist/session/manager.d.ts +27 -0
- package/dist/session/manager.js +187 -0
- package/dist/session/types.d.ts +101 -0
- package/dist/session/types.js +1 -0
- package/dist/tools/answer_question.d.ts +5 -0
- package/dist/tools/answer_question.js +52 -0
- package/dist/tools/approve_permission.d.ts +4 -0
- package/dist/tools/approve_permission.js +54 -0
- package/dist/tools/deny_permission.d.ts +4 -0
- package/dist/tools/deny_permission.js +31 -0
- package/dist/tools/get_session.d.ts +4 -0
- package/dist/tools/get_session.js +106 -0
- package/dist/tools/list_computers.d.ts +4 -0
- package/dist/tools/list_computers.js +36 -0
- package/dist/tools/list_sessions.d.ts +4 -0
- package/dist/tools/list_sessions.js +46 -0
- package/dist/tools/send_message.d.ts +4 -0
- package/dist/tools/send_message.js +54 -0
- package/dist/tools/start_session.d.ts +5 -0
- package/dist/tools/start_session.js +49 -0
- package/dist/tools/watch_session.d.ts +4 -0
- package/dist/tools/watch_session.js +91 -0
- package/dist/types/wire.d.ts +148 -0
- package/dist/types/wire.js +9 -0
- package/package.json +66 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createHmac, createHash, createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
|
2
|
+
import nacl from 'tweetnacl';
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Base64 Utilities
|
|
5
|
+
// ============================================================
|
|
6
|
+
export function encodeBase64(buf) {
|
|
7
|
+
return Buffer.from(buf).toString('base64');
|
|
8
|
+
}
|
|
9
|
+
export function decodeBase64(base64) {
|
|
10
|
+
return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
11
|
+
}
|
|
12
|
+
export function encodeBase64Url(buf) {
|
|
13
|
+
return Buffer.from(buf).toString('base64url');
|
|
14
|
+
}
|
|
15
|
+
export function decodeBase64Url(base64url) {
|
|
16
|
+
return new Uint8Array(Buffer.from(base64url, 'base64url'));
|
|
17
|
+
}
|
|
18
|
+
// ============================================================
|
|
19
|
+
// HMAC-SHA512
|
|
20
|
+
// ============================================================
|
|
21
|
+
export function hmacSha512(key, data) {
|
|
22
|
+
const hmac = createHmac('sha512', key);
|
|
23
|
+
hmac.update(data);
|
|
24
|
+
return new Uint8Array(hmac.digest());
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Derive root of key tree.
|
|
28
|
+
* CRITICAL: key = encode(usage + ' Master Seed'), data = seed
|
|
29
|
+
* Verified against upstream: packages/happy-agent/src/encryption.ts
|
|
30
|
+
*/
|
|
31
|
+
export function deriveSecretKeyTreeRoot(seed, usage) {
|
|
32
|
+
const I = hmacSha512(new TextEncoder().encode(usage + ' Master Seed'), seed);
|
|
33
|
+
return { key: I.slice(0, 32), chainCode: I.slice(32) };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Derive child key from chain code.
|
|
37
|
+
* data = [0x00, ...encode(index)]
|
|
38
|
+
*/
|
|
39
|
+
export function deriveSecretKeyTreeChild(chainCode, index) {
|
|
40
|
+
const indexBytes = new TextEncoder().encode(index);
|
|
41
|
+
const data = new Uint8Array(1 + indexBytes.length);
|
|
42
|
+
data[0] = 0x00;
|
|
43
|
+
data.set(indexBytes, 1);
|
|
44
|
+
const I = hmacSha512(chainCode, data);
|
|
45
|
+
return { key: I.slice(0, 32), chainCode: I.slice(32) };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Derive key at path. Root + iterate children.
|
|
49
|
+
* Test vectors:
|
|
50
|
+
* deriveKey(encode("test seed"), "test usage", [])
|
|
51
|
+
* => E6E55652456F9FE47D6FF46CA3614E85B499F77E7B340FBBB1553307CEDC1E74
|
|
52
|
+
* deriveKey(encode("test seed"), "test usage", ["child1", "child2"])
|
|
53
|
+
* => 1011C097D2105D27362B987A631496BBF68B836124D1D072E9D1613C6028CF75
|
|
54
|
+
*/
|
|
55
|
+
export function deriveKey(seed, usage, path) {
|
|
56
|
+
let state = deriveSecretKeyTreeRoot(seed, usage);
|
|
57
|
+
for (const index of path) {
|
|
58
|
+
state = deriveSecretKeyTreeChild(state.chainCode, index);
|
|
59
|
+
}
|
|
60
|
+
return state.key;
|
|
61
|
+
}
|
|
62
|
+
// ============================================================
|
|
63
|
+
// Content Keypair (Curve25519 for NaCl Box)
|
|
64
|
+
// ============================================================
|
|
65
|
+
/**
|
|
66
|
+
* CRITICAL: Has a SHA-512 step to match libsodium's crypto_box_seed_keypair behavior.
|
|
67
|
+
* 1. Derive seed via HMAC tree
|
|
68
|
+
* 2. SHA-512(seed)[0:32] = box secret key
|
|
69
|
+
* 3. nacl.box.keyPair.fromSecretKey(boxSecretKey)
|
|
70
|
+
*
|
|
71
|
+
* Source: packages/happy-agent/src/encryption.ts:deriveContentKeyPair
|
|
72
|
+
*/
|
|
73
|
+
export function deriveContentKeyPair(secret) {
|
|
74
|
+
const seed = deriveKey(secret, 'Happy EnCoder', ['content']);
|
|
75
|
+
const hashedSeed = new Uint8Array(createHash('sha512').update(seed).digest());
|
|
76
|
+
const boxSecretKey = hashedSeed.slice(0, 32);
|
|
77
|
+
return nacl.box.keyPair.fromSecretKey(boxSecretKey);
|
|
78
|
+
}
|
|
79
|
+
// ============================================================
|
|
80
|
+
// Auth Challenge (Ed25519 signing)
|
|
81
|
+
// ============================================================
|
|
82
|
+
/**
|
|
83
|
+
* Generate auth challenge for token refresh.
|
|
84
|
+
* CRITICAL: Uses nacl.sign.keyPair.fromSeed(secret) with the RAW account secret.
|
|
85
|
+
* NOT the derived content keypair (which is Curve25519, not Ed25519).
|
|
86
|
+
*
|
|
87
|
+
* Source: packages/happy-agent/src/encryption.ts:authChallenge
|
|
88
|
+
*/
|
|
89
|
+
export function authChallenge(secret) {
|
|
90
|
+
const signingKeyPair = nacl.sign.keyPair.fromSeed(secret);
|
|
91
|
+
const challenge = randomBytes(32);
|
|
92
|
+
const signature = nacl.sign.detached(challenge, signingKeyPair.secretKey);
|
|
93
|
+
return { challenge, publicKey: signingKeyPair.publicKey, signature };
|
|
94
|
+
}
|
|
95
|
+
// ============================================================
|
|
96
|
+
// NaCl Box (Key Exchange / Decryption)
|
|
97
|
+
// ============================================================
|
|
98
|
+
/**
|
|
99
|
+
* Decrypt a NaCl box bundle: [ephemeralPubKey(32)][nonce(24)][ciphertext]
|
|
100
|
+
* Returns decrypted Uint8Array or null on failure.
|
|
101
|
+
*
|
|
102
|
+
* Source: packages/happy-agent/src/encryption.ts
|
|
103
|
+
*/
|
|
104
|
+
export function decryptBoxBundle(bundle, recipientSecretKey) {
|
|
105
|
+
if (bundle.length < 56)
|
|
106
|
+
return null; // 32 + 24 minimum
|
|
107
|
+
const ephemeralPubKey = bundle.slice(0, 32);
|
|
108
|
+
const nonce = bundle.slice(32, 56);
|
|
109
|
+
const ciphertext = bundle.slice(56);
|
|
110
|
+
return nacl.box.open(ciphertext, nonce, ephemeralPubKey, recipientSecretKey);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Encrypt data for a public key using NaCl box.
|
|
114
|
+
* Returns: [ephemeralPubKey(32)][nonce(24)][ciphertext]
|
|
115
|
+
*/
|
|
116
|
+
export function encryptForPublicKey(data, recipientPublicKey) {
|
|
117
|
+
const ephemeral = nacl.box.keyPair();
|
|
118
|
+
const nonce = randomBytes(24);
|
|
119
|
+
const encrypted = nacl.box(data, nonce, recipientPublicKey, ephemeral.secretKey);
|
|
120
|
+
if (!encrypted)
|
|
121
|
+
throw new Error('NaCl box encryption failed');
|
|
122
|
+
const result = new Uint8Array(32 + 24 + encrypted.length);
|
|
123
|
+
result.set(ephemeral.publicKey, 0);
|
|
124
|
+
result.set(nonce, 32);
|
|
125
|
+
result.set(encrypted, 56);
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
// ============================================================
|
|
129
|
+
// AES-256-GCM (Modern encryption)
|
|
130
|
+
// ============================================================
|
|
131
|
+
/**
|
|
132
|
+
* Encrypt with AES-256-GCM.
|
|
133
|
+
* Bundle format: [version(1)=0x00][nonce(12)][ciphertext(N)][authTag(16)]
|
|
134
|
+
*/
|
|
135
|
+
export function encryptAesGcm(key, data) {
|
|
136
|
+
const nonce = randomBytes(12);
|
|
137
|
+
const plaintext = Buffer.from(JSON.stringify(data), 'utf-8');
|
|
138
|
+
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
|
139
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
140
|
+
const authTag = cipher.getAuthTag();
|
|
141
|
+
const result = new Uint8Array(1 + 12 + encrypted.length + 16);
|
|
142
|
+
result[0] = 0x00; // version byte
|
|
143
|
+
result.set(nonce, 1);
|
|
144
|
+
result.set(encrypted, 13);
|
|
145
|
+
result.set(authTag, 13 + encrypted.length);
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Decrypt AES-256-GCM bundle.
|
|
150
|
+
* Returns parsed JSON or null on failure.
|
|
151
|
+
*/
|
|
152
|
+
export function decryptAesGcm(key, bundle) {
|
|
153
|
+
if (bundle.length < 29)
|
|
154
|
+
return null; // 1 + 12 + 16 minimum
|
|
155
|
+
if (bundle[0] !== 0x00)
|
|
156
|
+
return null; // version check
|
|
157
|
+
const nonce = bundle.slice(1, 13);
|
|
158
|
+
const authTag = bundle.slice(bundle.length - 16);
|
|
159
|
+
const ciphertext = bundle.slice(13, bundle.length - 16);
|
|
160
|
+
try {
|
|
161
|
+
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
|
162
|
+
decipher.setAuthTag(authTag);
|
|
163
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
164
|
+
return JSON.parse(decrypted.toString('utf-8'));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// ============================================================
|
|
171
|
+
// NaCl SecretBox (Legacy encryption)
|
|
172
|
+
// ============================================================
|
|
173
|
+
/**
|
|
174
|
+
* Encrypt with NaCl SecretBox.
|
|
175
|
+
* Bundle format: [nonce(24)][ciphertext]
|
|
176
|
+
*/
|
|
177
|
+
export function encryptSecretBox(key, data) {
|
|
178
|
+
const nonce = randomBytes(24);
|
|
179
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
|
180
|
+
const encrypted = nacl.secretbox(plaintext, nonce, key);
|
|
181
|
+
const result = new Uint8Array(24 + encrypted.length);
|
|
182
|
+
result.set(nonce, 0);
|
|
183
|
+
result.set(encrypted, 24);
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Decrypt NaCl SecretBox bundle.
|
|
188
|
+
* Returns parsed JSON or null on failure.
|
|
189
|
+
*/
|
|
190
|
+
export function decryptSecretBox(key, bundle) {
|
|
191
|
+
if (bundle.length < 24)
|
|
192
|
+
return null;
|
|
193
|
+
const nonce = bundle.slice(0, 24);
|
|
194
|
+
const ciphertext = bundle.slice(24);
|
|
195
|
+
const decrypted = nacl.secretbox.open(ciphertext, nonce, key);
|
|
196
|
+
if (!decrypted)
|
|
197
|
+
return null;
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Encrypt data using the appropriate variant.
|
|
207
|
+
*/
|
|
208
|
+
export function encrypt(key, variant, data) {
|
|
209
|
+
return variant === 'dataKey' ? encryptAesGcm(key, data) : encryptSecretBox(key, data);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Decrypt data using the appropriate variant.
|
|
213
|
+
*/
|
|
214
|
+
export function decrypt(key, variant, bundle) {
|
|
215
|
+
return variant === 'dataKey' ? decryptAesGcm(key, bundle) : decryptSecretBox(key, bundle);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Encrypt data and return base64 string.
|
|
219
|
+
*/
|
|
220
|
+
export function encryptToBase64(encryption, data) {
|
|
221
|
+
return encodeBase64(encrypt(encryption.key, encryption.variant, data));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Decrypt base64-encoded data.
|
|
225
|
+
*/
|
|
226
|
+
export function decryptFromBase64(encryption, base64) {
|
|
227
|
+
return decrypt(encryption.key, encryption.variant, decodeBase64(base64));
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Resolve session encryption key.
|
|
231
|
+
* CRITICAL: Strip version byte (first byte) before NaCl box decryption.
|
|
232
|
+
* Pass contentKeyPair.secretKey, NOT the full keypair.
|
|
233
|
+
*
|
|
234
|
+
* Source: packages/happy-agent/src/api.ts:resolveSessionEncryption
|
|
235
|
+
*/
|
|
236
|
+
export function resolveSessionEncryption(dataEncryptionKey, credentials) {
|
|
237
|
+
if (dataEncryptionKey) {
|
|
238
|
+
const encrypted = decodeBase64(dataEncryptionKey);
|
|
239
|
+
// CRITICAL: Strip version byte (first byte, always 0x00)
|
|
240
|
+
const bundle = encrypted.slice(1);
|
|
241
|
+
const sessionKey = decryptBoxBundle(bundle, credentials.contentKeyPair.secretKey);
|
|
242
|
+
if (!sessionKey) {
|
|
243
|
+
throw new Error('Failed to decrypt session encryption key');
|
|
244
|
+
}
|
|
245
|
+
return { key: sessionKey, variant: 'dataKey' };
|
|
246
|
+
}
|
|
247
|
+
// Legacy: use account secret directly
|
|
248
|
+
return { key: credentials.secret, variant: 'legacy' };
|
|
249
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Credentials } from './crypto.js';
|
|
2
|
+
import type { Config } from '../config.js';
|
|
3
|
+
/**
|
|
4
|
+
* Load existing credentials or initiate pairing flow.
|
|
5
|
+
*/
|
|
6
|
+
export declare function loadOrPairCredentials(config: Config): Promise<Credentials>;
|
|
7
|
+
/**
|
|
8
|
+
* Perform the QR pairing flow.
|
|
9
|
+
* 1. Generate ephemeral Curve25519 keypair
|
|
10
|
+
* 2. POST public key to relay
|
|
11
|
+
* 3. Display QR code on stderr
|
|
12
|
+
* 4. Poll until authorized
|
|
13
|
+
* 5. Decrypt account secret
|
|
14
|
+
* 6. Persist credentials
|
|
15
|
+
*/
|
|
16
|
+
export declare function performPairing(config: Config): Promise<Credentials>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import nacl from 'tweetnacl';
|
|
3
|
+
import qrcode from 'qrcode-terminal';
|
|
4
|
+
import { encodeBase64, encodeBase64Url, decodeBase64, decryptBoxBundle } from './crypto.js';
|
|
5
|
+
import { writeCredentials, readCredentials, validateFilePermissions } from './credentials.js';
|
|
6
|
+
import { logger } from '../logger.js';
|
|
7
|
+
import { AuthError } from '../errors.js';
|
|
8
|
+
const POLL_INTERVAL = 1000;
|
|
9
|
+
const AUTH_TIMEOUT = 120_000;
|
|
10
|
+
function sleep(ms) {
|
|
11
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Load existing credentials or initiate pairing flow.
|
|
15
|
+
*/
|
|
16
|
+
export async function loadOrPairCredentials(config) {
|
|
17
|
+
const creds = readCredentials(config.credentialsPath);
|
|
18
|
+
if (creds) {
|
|
19
|
+
validateFilePermissions(config.credentialsPath);
|
|
20
|
+
logger.info('Loaded existing credentials');
|
|
21
|
+
return creds;
|
|
22
|
+
}
|
|
23
|
+
console.error('[happy-mcp] No credentials found. Starting pairing flow...');
|
|
24
|
+
return performPairing(config);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Perform the QR pairing flow.
|
|
28
|
+
* 1. Generate ephemeral Curve25519 keypair
|
|
29
|
+
* 2. POST public key to relay
|
|
30
|
+
* 3. Display QR code on stderr
|
|
31
|
+
* 4. Poll until authorized
|
|
32
|
+
* 5. Decrypt account secret
|
|
33
|
+
* 6. Persist credentials
|
|
34
|
+
*/
|
|
35
|
+
export async function performPairing(config) {
|
|
36
|
+
// 1. Generate ephemeral Curve25519 keypair
|
|
37
|
+
const seed = nacl.randomBytes(32);
|
|
38
|
+
const keypair = nacl.box.keyPair.fromSecretKey(seed);
|
|
39
|
+
const publicKeyBase64 = encodeBase64(keypair.publicKey);
|
|
40
|
+
const publicKeyBase64Url = encodeBase64Url(keypair.publicKey);
|
|
41
|
+
// 2. POST to /v1/auth/account/request
|
|
42
|
+
try {
|
|
43
|
+
await axios.post(`${config.serverUrl}/v1/auth/account/request`, {
|
|
44
|
+
publicKey: publicKeyBase64,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new AuthError(`Failed to initiate pairing: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
// 3. Display QR code on stderr
|
|
51
|
+
const qrData = `happy:///account?${publicKeyBase64Url}`;
|
|
52
|
+
console.error('\nScan this QR code with the Happy app to pair:\n');
|
|
53
|
+
qrcode.generate(qrData, { small: true }, (code) => {
|
|
54
|
+
console.error(code);
|
|
55
|
+
});
|
|
56
|
+
console.error(`\nOr visit: https://app.happy.engineering/account/connect#key=${publicKeyBase64Url}\n`);
|
|
57
|
+
console.error('Waiting for authorization...');
|
|
58
|
+
// 4. Poll until authorized (with timeout)
|
|
59
|
+
const startTime = Date.now();
|
|
60
|
+
while (Date.now() - startTime < AUTH_TIMEOUT) {
|
|
61
|
+
await sleep(POLL_INTERVAL);
|
|
62
|
+
try {
|
|
63
|
+
const res = await axios.post(`${config.serverUrl}/v1/auth/account/request`, {
|
|
64
|
+
publicKey: publicKeyBase64,
|
|
65
|
+
});
|
|
66
|
+
if (res.data.state === 'authorized') {
|
|
67
|
+
// 5. Decrypt the account secret
|
|
68
|
+
const encryptedResponse = decodeBase64(res.data.response);
|
|
69
|
+
const accountSecret = decryptBoxBundle(encryptedResponse, keypair.secretKey);
|
|
70
|
+
if (!accountSecret) {
|
|
71
|
+
throw new AuthError('Failed to decrypt pairing response');
|
|
72
|
+
}
|
|
73
|
+
// 6. Persist credentials
|
|
74
|
+
writeCredentials(config.credentialsPath, res.data.token, accountSecret, config.serverUrl);
|
|
75
|
+
console.error('[happy-mcp] Paired successfully!');
|
|
76
|
+
const creds = readCredentials(config.credentialsPath);
|
|
77
|
+
if (!creds) {
|
|
78
|
+
throw new AuthError('Failed to read back written credentials');
|
|
79
|
+
}
|
|
80
|
+
return creds;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err instanceof AuthError)
|
|
85
|
+
throw err;
|
|
86
|
+
logger.debug('Polling...', err.message);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw new AuthError('Pairing timed out after 2 minutes. Please try again.');
|
|
90
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Credentials } from './crypto.js';
|
|
2
|
+
import type { Config } from '../config.js';
|
|
3
|
+
/**
|
|
4
|
+
* Refresh the JWT token using Ed25519 challenge-response.
|
|
5
|
+
* Uses a single-flight mutex so concurrent callers share one refresh.
|
|
6
|
+
*
|
|
7
|
+
* Endpoint: POST /v1/auth
|
|
8
|
+
* Body: { challenge: base64, publicKey: base64, signature: base64 }
|
|
9
|
+
* Response: { success: boolean, token: string }
|
|
10
|
+
*/
|
|
11
|
+
export declare function refreshToken(credentials: Credentials, config: Config): Promise<string>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { authChallenge, encodeBase64 } from './crypto.js';
|
|
3
|
+
import { writeCredentials } from './credentials.js';
|
|
4
|
+
import { logger } from '../logger.js';
|
|
5
|
+
import { AuthError } from '../errors.js';
|
|
6
|
+
let refreshPromise = null;
|
|
7
|
+
/**
|
|
8
|
+
* Refresh the JWT token using Ed25519 challenge-response.
|
|
9
|
+
* Uses a single-flight mutex so concurrent callers share one refresh.
|
|
10
|
+
*
|
|
11
|
+
* Endpoint: POST /v1/auth
|
|
12
|
+
* Body: { challenge: base64, publicKey: base64, signature: base64 }
|
|
13
|
+
* Response: { success: boolean, token: string }
|
|
14
|
+
*/
|
|
15
|
+
export async function refreshToken(credentials, config) {
|
|
16
|
+
// Single-flight: if a refresh is already in progress, return that promise
|
|
17
|
+
if (refreshPromise) {
|
|
18
|
+
return refreshPromise;
|
|
19
|
+
}
|
|
20
|
+
refreshPromise = doRefresh(credentials, config).finally(() => {
|
|
21
|
+
refreshPromise = null;
|
|
22
|
+
});
|
|
23
|
+
return refreshPromise;
|
|
24
|
+
}
|
|
25
|
+
async function doRefresh(credentials, config) {
|
|
26
|
+
logger.info('Refreshing JWT token...');
|
|
27
|
+
const { challenge, publicKey, signature } = authChallenge(credentials.secret);
|
|
28
|
+
try {
|
|
29
|
+
const res = await axios.post(`${config.serverUrl}/v1/auth`, {
|
|
30
|
+
challenge: encodeBase64(challenge),
|
|
31
|
+
publicKey: encodeBase64(publicKey),
|
|
32
|
+
signature: encodeBase64(signature),
|
|
33
|
+
});
|
|
34
|
+
if (!res.data.success || !res.data.token) {
|
|
35
|
+
throw new AuthError('Token refresh failed: server returned unsuccessful response');
|
|
36
|
+
}
|
|
37
|
+
const newToken = res.data.token;
|
|
38
|
+
// Update credentials on disk (preserve serverUrl from config)
|
|
39
|
+
writeCredentials(config.credentialsPath, newToken, credentials.secret, config.serverUrl);
|
|
40
|
+
// Update in-memory token
|
|
41
|
+
credentials.token = newToken;
|
|
42
|
+
logger.info('Token refreshed successfully');
|
|
43
|
+
return newToken;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err instanceof AuthError)
|
|
47
|
+
throw err;
|
|
48
|
+
throw new AuthError(`Token refresh failed: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
export interface Config {
|
|
3
|
+
serverUrl: string;
|
|
4
|
+
computers: string[];
|
|
5
|
+
projectPaths: string[];
|
|
6
|
+
credentialsPath: string;
|
|
7
|
+
logLevel: LogLevel;
|
|
8
|
+
sessionCacheTtl: number;
|
|
9
|
+
enableStart: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function loadConfig(): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { hostname } from 'os';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
export function loadConfig() {
|
|
5
|
+
const serverUrl = (process.env.HAPPY_SERVER_URL ?? 'https://api.cluster-fluster.com').replace(/\/+$/, '');
|
|
6
|
+
const computers = process.env.HAPPY_MCP_COMPUTERS?.split(',').map(s => s.trim()).filter(Boolean) ?? [hostname()];
|
|
7
|
+
const projectPaths = process.env.HAPPY_MCP_PROJECT_PATHS?.split(',').map(s => s.trim()).filter(Boolean) ?? [process.cwd()];
|
|
8
|
+
const credentialsPath = process.env.HAPPY_MCP_CREDENTIALS_PATH ?? join(homedir(), '.happy-mcp', 'credentials.json');
|
|
9
|
+
const logLevel = (process.env.HAPPY_MCP_LOG_LEVEL ?? 'warn');
|
|
10
|
+
const sessionCacheTtl = parseInt(process.env.HAPPY_MCP_SESSION_CACHE_TTL ?? '300', 10);
|
|
11
|
+
const enableStart = process.env.HAPPY_MCP_ENABLE_START !== 'false';
|
|
12
|
+
return { serverUrl, computers, projectPaths, credentialsPath, logLevel, sessionCacheTtl, enableStart };
|
|
13
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare class HappyMcpError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare class CryptoError extends HappyMcpError {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare class AuthError extends HappyMcpError {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
export declare class RelayError extends HappyMcpError {
|
|
11
|
+
constructor(message: string);
|
|
12
|
+
}
|
|
13
|
+
export declare class ToolError extends HappyMcpError {
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export class HappyMcpError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'HappyMcpError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class CryptoError extends HappyMcpError {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'CryptoError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class AuthError extends HappyMcpError {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'AuthError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class RelayError extends HappyMcpError {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'RelayError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class ToolError extends HappyMcpError {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'ToolError';
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/index.d.ts
ADDED