moeba-claude-channel 0.0.8 → 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.
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "moeba-marketplace",
3
+ "owner": {
4
+ "name": "Moeba",
5
+ "email": "hello@moeba.co.za"
6
+ },
7
+ "plugins": [
8
+ {
9
+ "name": "moeba-channel",
10
+ "source": {
11
+ "source": "npm",
12
+ "package": "moeba-claude-channel"
13
+ },
14
+ "description": "Chat with Claude Code from the Moeba mobile app",
15
+ "version": "0.0.8"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "moeba-channel",
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.8",
5
+ "author": {
6
+ "name": "Moeba",
7
+ "email": "hello@moeba.co.za"
8
+ },
9
+ "homepage": "https://moeba.co.za",
10
+ "repository": "https://github.com/moeba-co/moeba",
11
+ "license": "MIT"
12
+ }
package/.mcp.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "mcpServers": {
3
+ "moeba": {
4
+ "command": "node",
5
+ "args": ["dist/moeba-channel.js", "--login"],
6
+ "autoApprove": ["moeba_reply", "moeba_progress"]
7
+ }
8
+ }
9
+ }
@@ -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(c, mcp) {
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
- let creds = loadCredentials();
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
- try {
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(creds, mcp);
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.8",
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",
@@ -9,6 +9,8 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
+ ".claude-plugin",
13
+ ".mcp.json",
12
14
  "README.md"
13
15
  ],
14
16
  "scripts": {