moeba-claude-channel 0.0.9 → 0.0.10
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.
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
"plugins": [
|
|
8
8
|
{
|
|
9
9
|
"name": "moeba-channel",
|
|
10
|
-
"source":
|
|
10
|
+
"source": {
|
|
11
|
+
"source": "npm",
|
|
12
|
+
"package": "moeba-claude-channel"
|
|
13
|
+
},
|
|
11
14
|
"description": "Chat with Claude Code from the Moeba mobile app",
|
|
12
|
-
"version": "0.0.
|
|
15
|
+
"version": "0.0.8"
|
|
13
16
|
}
|
|
14
17
|
]
|
|
15
18
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moeba-channel",
|
|
3
3
|
"description": "Chat with Claude Code from the Moeba mobile app. Two-way bridge that lets you send messages from your phone and get responses from your Claude Code session.",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.8",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Moeba",
|
|
7
7
|
"email": "hello@moeba.co.za"
|
|
8
8
|
},
|
|
9
9
|
"homepage": "https://moeba.co.za",
|
|
10
|
-
"repository": "https://github.com/
|
|
10
|
+
"repository": "https://github.com/moeba-co/moeba",
|
|
11
11
|
"license": "MIT"
|
|
12
12
|
}
|
package/dist/moeba-channel.js
CHANGED
|
@@ -60,6 +60,35 @@ function saveCredentials(c) {
|
|
|
60
60
|
mkdirSync(dir, { recursive: true });
|
|
61
61
|
writeFileSync(CREDENTIALS_PATH, JSON.stringify(c, null, 2));
|
|
62
62
|
}
|
|
63
|
+
// Module-level credentials so the SSE loop and tool handlers always read the
|
|
64
|
+
// freshest token after a refresh.
|
|
65
|
+
let creds = null;
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Session token (JWT) expiry — the session token is short-lived (~30 days),
|
|
68
|
+
// so we refresh it proactively rather than wedging on a dead token.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
function decodeJwtExp(token) {
|
|
71
|
+
try {
|
|
72
|
+
const part = token.split('.')[1];
|
|
73
|
+
if (!part)
|
|
74
|
+
return null;
|
|
75
|
+
const json = Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8');
|
|
76
|
+
const payload = JSON.parse(json);
|
|
77
|
+
return typeof payload.exp === 'number' ? payload.exp : null;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// True when the token expires within `skewSeconds`. Returns false if the token
|
|
84
|
+
// has no decodable exp — in that case we let the server be the judge (the SSE
|
|
85
|
+
// 401 handler is the backstop) rather than forcing a refresh on every connect.
|
|
86
|
+
function isTokenExpired(token, skewSeconds = 300) {
|
|
87
|
+
const exp = decodeJwtExp(token);
|
|
88
|
+
if (exp === null)
|
|
89
|
+
return false;
|
|
90
|
+
return Date.now() / 1000 >= exp - skewSeconds;
|
|
91
|
+
}
|
|
63
92
|
// ---------------------------------------------------------------------------
|
|
64
93
|
// OAuth flow — opens browser, receives Firebase token via localhost callback
|
|
65
94
|
// ---------------------------------------------------------------------------
|
|
@@ -136,19 +165,109 @@ function authenticate() {
|
|
|
136
165
|
});
|
|
137
166
|
}
|
|
138
167
|
// ---------------------------------------------------------------------------
|
|
168
|
+
// Headless auth — API key + email (no browser). Used for first-run headless
|
|
169
|
+
// setup and for unattended token refresh once the session token expires.
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
async function authenticateViaApiKey(apiKey, email) {
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetch(`${MOEBA_API_URL}/channel/auth`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'Content-Type': 'application/json' },
|
|
176
|
+
body: JSON.stringify({ apiKey, email, projectName: PROJECT_NAME }),
|
|
177
|
+
});
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
console.error(`API key auth failed: ${await response.text()}`);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const data = (await response.json());
|
|
183
|
+
const newCreds = {
|
|
184
|
+
token: data.token,
|
|
185
|
+
email: data.email,
|
|
186
|
+
businessId: data.businessId,
|
|
187
|
+
agentId: data.agentId,
|
|
188
|
+
connectionId: data.connectionId,
|
|
189
|
+
agentApiKey: data.agentApiKey,
|
|
190
|
+
projectName: PROJECT_NAME,
|
|
191
|
+
};
|
|
192
|
+
saveCredentials(newCreds);
|
|
193
|
+
return newCreds;
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
console.error(`API key auth error: ${err.message}`);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Re-mint the short-lived session token when it expires. The agent API key
|
|
201
|
+
// stored alongside it is long-lived and re-mints a token unattended, so a
|
|
202
|
+
// configured channel self-heals with no user login, env vars, or browser.
|
|
203
|
+
// Order: stored agent key → env API key (first-run) → browser OAuth.
|
|
204
|
+
async function refreshCredentials(opts) {
|
|
205
|
+
// 1. Best path: the already-stored agent key is non-expiring and works
|
|
206
|
+
// unattended inside the SSE reconnect loop.
|
|
207
|
+
if (creds?.agentApiKey && creds?.email) {
|
|
208
|
+
console.error('Refreshing Moeba session token via stored agent key...');
|
|
209
|
+
const refreshed = await authenticateViaApiKey(creds.agentApiKey, creds.email);
|
|
210
|
+
if (refreshed)
|
|
211
|
+
return refreshed;
|
|
212
|
+
}
|
|
213
|
+
// 2. Headless env credentials (e.g. first run before anything is cached).
|
|
214
|
+
const envApiKey = process.env.MOEBA_API_KEY;
|
|
215
|
+
const envEmail = process.env.MOEBA_EMAIL;
|
|
216
|
+
if (envApiKey && envEmail) {
|
|
217
|
+
console.error('Refreshing Moeba session token via API key...');
|
|
218
|
+
const refreshed = await authenticateViaApiKey(envApiKey, envEmail);
|
|
219
|
+
if (refreshed)
|
|
220
|
+
return refreshed;
|
|
221
|
+
}
|
|
222
|
+
// 3. Last resort: interactive browser OAuth.
|
|
223
|
+
if (opts.allowBrowser) {
|
|
224
|
+
console.error('Refreshing Moeba session token — opening browser...');
|
|
225
|
+
try {
|
|
226
|
+
return await authenticate();
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
console.error(`Re-authentication failed: ${err.message}`);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
console.error('Moeba session token expired and cannot refresh — reconnect with --login or set MOEBA_API_KEY + MOEBA_EMAIL.');
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
139
237
|
// SSE client — connects to Moeba and receives messages
|
|
140
238
|
// ---------------------------------------------------------------------------
|
|
141
|
-
function connectSSE(
|
|
142
|
-
const url = `${MOEBA_API_URL}/api/channel/events?connectionId=${c.connectionId}`;
|
|
239
|
+
function connectSSE(mcp) {
|
|
143
240
|
let reconnectDelay = 1000;
|
|
144
241
|
async function connect() {
|
|
242
|
+
if (!creds) {
|
|
243
|
+
console.error('Moeba SSE: no credentials — not connecting.');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
145
246
|
try {
|
|
247
|
+
// Proactively refresh a token that's expired/near-expiry so we don't
|
|
248
|
+
// burn a guaranteed-401 connect attempt.
|
|
249
|
+
if (isTokenExpired(creds.token)) {
|
|
250
|
+
const refreshed = await refreshCredentials({ allowBrowser: false });
|
|
251
|
+
if (refreshed)
|
|
252
|
+
creds = refreshed;
|
|
253
|
+
}
|
|
254
|
+
const c = creds; // snapshot for the life of this connection
|
|
255
|
+
const url = `${MOEBA_API_URL}/api/channel/events?connectionId=${c.connectionId}`;
|
|
146
256
|
console.error('Connecting to Moeba SSE...');
|
|
147
257
|
const response = await fetch(url, {
|
|
148
258
|
headers: { Authorization: `Bearer ${c.token}` },
|
|
149
259
|
});
|
|
150
260
|
if (!response.ok) {
|
|
151
261
|
console.error(`SSE connection failed: ${response.status}`);
|
|
262
|
+
// 401/403 → the session token was rejected. Re-authenticate (headless
|
|
263
|
+
// only — we're past startup with no TTY) and reconnect promptly.
|
|
264
|
+
if (response.status === 401 || response.status === 403) {
|
|
265
|
+
const refreshed = await refreshCredentials({ allowBrowser: false });
|
|
266
|
+
if (refreshed) {
|
|
267
|
+
creds = refreshed;
|
|
268
|
+
reconnectDelay = 1000;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
152
271
|
scheduleReconnect();
|
|
153
272
|
return;
|
|
154
273
|
}
|
|
@@ -244,43 +363,25 @@ async function main() {
|
|
|
244
363
|
const loginMode = process.argv.includes('--login');
|
|
245
364
|
const envApiKey = process.env.MOEBA_API_KEY;
|
|
246
365
|
const envEmail = process.env.MOEBA_EMAIL;
|
|
247
|
-
|
|
366
|
+
creds = loadCredentials();
|
|
248
367
|
if (!creds && envApiKey && envEmail) {
|
|
249
368
|
// Mode B: headless auth via API key + email
|
|
250
369
|
console.error(`Authenticating as ${envEmail} via API key...`);
|
|
251
|
-
|
|
252
|
-
const response = await fetch(`${MOEBA_API_URL}/channel/auth`, {
|
|
253
|
-
method: 'POST',
|
|
254
|
-
headers: { 'Content-Type': 'application/json' },
|
|
255
|
-
body: JSON.stringify({ apiKey: envApiKey, email: envEmail, projectName: PROJECT_NAME }),
|
|
256
|
-
});
|
|
257
|
-
if (!response.ok) {
|
|
258
|
-
const err = await response.text();
|
|
259
|
-
console.error(`API key auth failed: ${err}`);
|
|
260
|
-
}
|
|
261
|
-
else {
|
|
262
|
-
const data = (await response.json());
|
|
263
|
-
creds = {
|
|
264
|
-
token: data.token,
|
|
265
|
-
email: data.email,
|
|
266
|
-
businessId: data.businessId,
|
|
267
|
-
agentId: data.agentId,
|
|
268
|
-
connectionId: data.connectionId,
|
|
269
|
-
agentApiKey: data.agentApiKey,
|
|
270
|
-
projectName: PROJECT_NAME,
|
|
271
|
-
};
|
|
272
|
-
saveCredentials(creds);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
catch (err) {
|
|
276
|
-
console.error(`API key auth error: ${err.message}`);
|
|
277
|
-
}
|
|
370
|
+
creds = await authenticateViaApiKey(envApiKey, envEmail);
|
|
278
371
|
}
|
|
279
372
|
else if (!creds && loginMode) {
|
|
280
373
|
// Mode C: browser OAuth
|
|
281
374
|
console.error('No Moeba credentials found — opening browser to sign in...');
|
|
282
375
|
creds = await authenticate();
|
|
283
376
|
}
|
|
377
|
+
// Refresh an expired/near-expiry cached token so we never connect with a
|
|
378
|
+
// dead JWT (the bug that silently killed message delivery + notifications).
|
|
379
|
+
if (creds && isTokenExpired(creds.token)) {
|
|
380
|
+
console.error('Cached Moeba session token is expired or near expiry — refreshing...');
|
|
381
|
+
const refreshed = await refreshCredentials({ allowBrowser: loginMode });
|
|
382
|
+
if (refreshed)
|
|
383
|
+
creds = refreshed;
|
|
384
|
+
}
|
|
284
385
|
if (creds) {
|
|
285
386
|
console.error(`Authenticated as ${creds.email} (project: ${PROJECT_NAME})`);
|
|
286
387
|
}
|
|
@@ -396,7 +497,7 @@ IMPORTANT: The user is on a mobile app and CANNOT approve terminal permissions.
|
|
|
396
497
|
await mcp.connect(new StdioServerTransport());
|
|
397
498
|
// 5. Start SSE listener (only if authenticated)
|
|
398
499
|
if (creds) {
|
|
399
|
-
connectSSE(
|
|
500
|
+
connectSSE(mcp);
|
|
400
501
|
console.error('Moeba channel ready — waiting for messages');
|
|
401
502
|
}
|
|
402
503
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moeba-claude-channel",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "Claude Code channel for Moeba — chat with Claude Code from the Moeba app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/moeba-channel.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "https://github.com/
|
|
30
|
+
"url": "https://github.com/moeba-co/moeba"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@modelcontextprotocol/sdk": "^1.12.1"
|