viber-channel 0.5.1 → 0.5.2

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.
@@ -1,5 +1,6 @@
1
1
  import { basename } from "node:path";
2
2
  import { cfAccessHeaders } from "./cfAccess.js";
3
+ import { ConversationTokenExpiredError } from "./messages.js";
3
4
 
4
5
  export interface ConversationMintResponse {
5
6
  conversation_id: string;
@@ -76,6 +77,100 @@ export async function mintConversation(
76
77
  return (await resp.json()) as ConversationMintResponse;
77
78
  }
78
79
 
80
+ export interface RefreshTokenResponse {
81
+ conversation_token: string;
82
+ expires_at: number;
83
+ }
84
+
85
+ /**
86
+ * HTTP failure from /refresh-token (any non-401 non-2xx). `.retryable` lets
87
+ * the scheduler distinguish a transient server hiccup (5xx, 408, 409) from a
88
+ * permanent state mismatch (4xx other than 401), so it can back off and try
89
+ * again instead of immediately ending the channel.
90
+ */
91
+ export class RefreshHttpError extends Error {
92
+ status: number;
93
+ detail: string;
94
+ retryable: boolean;
95
+ constructor(status: number, detail: string) {
96
+ super(`Refresh failed (HTTP ${status}): ${detail}`);
97
+ this.name = "RefreshHttpError";
98
+ this.status = status;
99
+ this.detail = detail;
100
+ // 5xx, 408 (timeout), 409 (concurrent-refresh race) — server/transient.
101
+ // 4xx others (403 fingerprint mismatch, etc.) are permanent.
102
+ this.retryable = status >= 500 || status === 408 || status === 409;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Wrapper for `fetch` failures (DNS, connection reset, TLS, etc.). Always
108
+ * retryable — by definition we never got a server response to classify.
109
+ */
110
+ export class RefreshNetworkError extends Error {
111
+ cause: unknown;
112
+ retryable: true = true as const;
113
+ constructor(cause: unknown) {
114
+ super(`Refresh failed: network error: ${String(cause)}`);
115
+ this.name = "RefreshNetworkError";
116
+ this.cause = cause;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * POST /api/conversations/:conversationId/refresh-token
122
+ *
123
+ * Rotates the per-conversation token in place. Worker updates the row's
124
+ * token+expiry and returns the new pair. The caller (scheduler) propagates
125
+ * the new token to module state so subsequent requests use it.
126
+ *
127
+ * @throws ConversationTokenExpiredError on HTTP 401 (token is unknown or has
128
+ * already been rotated away from the value sent in the Authorization header)
129
+ * @throws RefreshHttpError on any other non-2xx status — inspect `.retryable`
130
+ * @throws RefreshNetworkError on fetch-level failure (always retryable)
131
+ */
132
+ export async function refreshConversationToken(
133
+ baseUrl: string,
134
+ conversationId: string,
135
+ currentToken: string,
136
+ fingerprint: string,
137
+ ): Promise<RefreshTokenResponse> {
138
+ let resp: Response;
139
+ try {
140
+ resp = await fetch(
141
+ `${baseUrl}/api/conversations/${conversationId}/refresh-token`,
142
+ {
143
+ method: "POST",
144
+ headers: {
145
+ Authorization: `Bearer ${currentToken}`,
146
+ "X-Client-Fingerprint": fingerprint,
147
+ "Content-Type": "application/json",
148
+ ...cfAccessHeaders(),
149
+ },
150
+ body: "{}",
151
+ },
152
+ );
153
+ } catch (err) {
154
+ throw new RefreshNetworkError(err);
155
+ }
156
+
157
+ if (resp.status === 401) {
158
+ throw new ConversationTokenExpiredError();
159
+ }
160
+
161
+ if (!resp.ok) {
162
+ let detail = "";
163
+ try {
164
+ detail = await resp.text();
165
+ } catch {
166
+ // ignore
167
+ }
168
+ throw new RefreshHttpError(resp.status, detail);
169
+ }
170
+
171
+ return (await resp.json()) as RefreshTokenResponse;
172
+ }
173
+
79
174
  export function defaultLabel(folderPath: string): string {
80
175
  const folder = basename(folderPath);
81
176
  // Local time, not UTC — the user sees this label in the UI; UTC was confusing.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Token refresh scheduler — single-responsibility module.
3
+ *
4
+ * Fires a `refresh()` callback shortly before the conversation token expires,
5
+ * re-schedules itself on success, and routes failures to `onFailure()`. No
6
+ * knowledge of the worker, MCP server, or any channel module state.
7
+ */
8
+
9
+ export interface TokenRefreshState {
10
+ /** Unix seconds — the new expiry returned by the refresh callback. */
11
+ expiresAt: number;
12
+ }
13
+
14
+ export interface TokenRefreshDeps {
15
+ /** Returns current time in unix seconds. Defaults to Date.now()/1000. */
16
+ now?: () => number;
17
+ /** Schedules a callback after `ms` milliseconds. Defaults to setTimeout. */
18
+ setTimer?: (fn: () => void, ms: number) => unknown;
19
+ /** Cancels a previously-scheduled timer. Defaults to clearTimeout. */
20
+ clearTimer?: (h: unknown) => void;
21
+ /** Seconds before expiry to trigger the refresh. Default 300 (5 minutes). */
22
+ leadSeconds?: number;
23
+ /** Log sink. Defaults to writing to process.stderr with a newline. */
24
+ log?: (line: string) => void;
25
+ }
26
+
27
+ export interface TokenRefreshScheduler {
28
+ /** Start the scheduler with the initial token expiry (unix seconds). */
29
+ start: (initialExpiresAt: number) => void;
30
+ /** Cancel any pending refresh. Idempotent. Stops re-scheduling. */
31
+ cancel: () => void;
32
+ }
33
+
34
+ const DEFAULT_LEAD_SECONDS = 300;
35
+
36
+ type SchedulerState = "active" | "cancelled";
37
+
38
+ export function createTokenRefreshScheduler(
39
+ refresh: () => Promise<TokenRefreshState>,
40
+ onFailure: (err: unknown) => void | Promise<void>,
41
+ deps: TokenRefreshDeps = {},
42
+ ): TokenRefreshScheduler {
43
+ const now = deps.now ?? (() => Date.now() / 1000);
44
+ const setTimer =
45
+ deps.setTimer ??
46
+ ((fn: () => void, ms: number) => setTimeout(fn, ms) as unknown);
47
+ const clearTimer = deps.clearTimer ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>));
48
+ const leadSeconds = deps.leadSeconds ?? DEFAULT_LEAD_SECONDS;
49
+ const log = deps.log ?? ((line: string) => process.stderr.write(`${line}\n`));
50
+
51
+ let handle: unknown = null;
52
+ let state: SchedulerState = "active";
53
+
54
+ const scheduleAt = (expiresAt: number): void => {
55
+ if (state === "cancelled") return;
56
+ const delayMs = Math.max(0, (expiresAt - leadSeconds - now()) * 1000);
57
+ handle = setTimer(fire, delayMs);
58
+ };
59
+
60
+ const fire = (): void => {
61
+ handle = null;
62
+ // void intentional — timer callback cannot be async.
63
+ void runRefresh();
64
+ };
65
+
66
+ const runRefresh = async (): Promise<void> => {
67
+ try {
68
+ const result = await refresh();
69
+ if (state === "cancelled") return;
70
+ const ttlSeconds = Math.max(0, result.expiresAt - now());
71
+ const nextSeconds = Math.max(0, ttlSeconds - leadSeconds);
72
+ const ttlMin = Math.round(ttlSeconds / 60);
73
+ const nextMin = Math.round(nextSeconds / 60);
74
+ log(
75
+ `[viber-channel] token refreshed, next refresh in ${nextMin}m (ttl ${ttlMin}m)`,
76
+ );
77
+ scheduleAt(result.expiresAt);
78
+ } catch (err) {
79
+ try {
80
+ await onFailure(err);
81
+ } catch {
82
+ // Failure callback owns its own error path; swallow.
83
+ }
84
+ }
85
+ };
86
+
87
+ return {
88
+ start(initialExpiresAt: number): void {
89
+ if (state === "cancelled") return;
90
+ if (handle !== null) {
91
+ clearTimer(handle);
92
+ handle = null;
93
+ }
94
+ scheduleAt(initialExpiresAt);
95
+ },
96
+ cancel(): void {
97
+ state = "cancelled";
98
+ if (handle !== null) {
99
+ clearTimer(handle);
100
+ handle = null;
101
+ }
102
+ },
103
+ };
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
5
5
  "type": "module",
6
6
  "bin": {
package/viber-channel.ts CHANGED
@@ -31,6 +31,10 @@ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
31
31
  import { join } from "node:path";
32
32
  import { runConnect } from "./lib/connect.ts";
33
33
  import { lockFilePath } from "./lib/lockfile.ts";
34
+ import {
35
+ createTokenRefreshScheduler,
36
+ type TokenRefreshScheduler,
37
+ } from "./lib/token_refresh.ts";
34
38
 
35
39
  // ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
36
40
  //
@@ -138,10 +142,15 @@ function releaseLock(): void {
138
142
  }
139
143
  }
140
144
 
145
+ // Token-refresh scheduler — null until the initial mint succeeds. Declared at
146
+ // module scope so the early exit handlers below (which fire before the mint
147
+ // completes on slow boots) can cancel it idempotently via optional-chaining.
148
+ let scheduler: TokenRefreshScheduler | null = null;
149
+
141
150
  // Best-effort cleanup — on Windows, signals may not fire (TerminateProcess)
142
- process.on("exit", releaseLock);
143
- process.on("SIGINT", () => { releaseLock(); process.exit(0); });
144
- process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
151
+ process.on("exit", () => { scheduler?.cancel(); releaseLock(); });
152
+ process.on("SIGINT", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
153
+ process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
145
154
 
146
155
  // stdin EOF — Claude Code closes its end of the pipe when the session
147
156
  // terminates (or the parent process is killed via TerminateProcess on
@@ -158,6 +167,7 @@ process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
158
167
  // orphan detection. See plan #236 step-09 for the diagnosis.
159
168
  process.stdin.on("end", () => {
160
169
  process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
170
+ scheduler?.cancel();
161
171
  releaseLock();
162
172
  process.exit(0);
163
173
  });
@@ -185,7 +195,14 @@ if (computedFp !== auth.client_fingerprint) {
185
195
 
186
196
  // ---- Imports for mint ----
187
197
 
188
- import { defaultLabel, mintConversation, ReverifyRequiredError } from "./lib/conversation.ts";
198
+ import {
199
+ defaultLabel,
200
+ mintConversation,
201
+ refreshConversationToken,
202
+ RefreshHttpError,
203
+ RefreshNetworkError,
204
+ ReverifyRequiredError,
205
+ } from "./lib/conversation.ts";
189
206
  import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
190
207
  import { cfAccessHeaders } from "./lib/cfAccess.ts";
191
208
 
@@ -376,6 +393,93 @@ try {
376
393
  CONVERSATION_TOKEN = minted.conversation_token;
377
394
  VOICE_BASE_URL = minted.ws_url;
378
395
  CONVERSATION_ID = minted.conversation_id;
396
+
397
+ // Schedule silent token refresh ahead of expiry (#237 step-03). On success
398
+ // we mutate CONVERSATION_TOKEN in place — getHeaders() reads it by closure,
399
+ // so subsequent SSE reconnects and send_message calls pick up the new token
400
+ // automatically. On failure we fall through to the existing
401
+ // handleConversationTokenExpired path (notification + exit code 3).
402
+ //
403
+ // VIBER_REFRESH_LEAD_SECONDS overrides the default 300s lead window — used
404
+ // for shortening the refresh-before-expiry gap during E2E manual testing
405
+ // (set close to TTL to force a refresh seconds after startup). Out of band
406
+ // for normal operation; the scheduler module's default applies when unset.
407
+ const leadOverride = process.env.VIBER_REFRESH_LEAD_SECONDS;
408
+ const leadSeconds = leadOverride ? Number.parseInt(leadOverride, 10) : undefined;
409
+ if (leadOverride !== undefined) {
410
+ if (!Number.isFinite(leadSeconds) || (leadSeconds as number) < 0) {
411
+ process.stderr.write(
412
+ `[viber-channel] Invalid VIBER_REFRESH_LEAD_SECONDS=${leadOverride}, ignoring.\n`
413
+ );
414
+ } else {
415
+ process.stderr.write(
416
+ `[viber-channel] token refresh lead overridden to ${leadSeconds}s via VIBER_REFRESH_LEAD_SECONDS\n`
417
+ );
418
+ }
419
+ }
420
+ const validLead =
421
+ leadSeconds !== undefined && Number.isFinite(leadSeconds) && leadSeconds >= 0
422
+ ? leadSeconds
423
+ : undefined;
424
+
425
+ // Track the current token's expiry so the retry path can refuse to retry
426
+ // past it. Updated only after a successful refresh — failed attempts leave
427
+ // it pointing at the still-valid current token.
428
+ let currentExpiresAt = minted.expires_at;
429
+
430
+ scheduler = createTokenRefreshScheduler(
431
+ async () => {
432
+ // Retry transient failures (5xx, 408, 409, network) with exponential
433
+ // backoff so a momentary blip during the 5-min lead window doesn't kill
434
+ // a channel whose token is still valid. Permanent failures (401, 403)
435
+ // surface immediately.
436
+ const MAX_ATTEMPTS = 5;
437
+ const HEADROOM_SECONDS = 10;
438
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
439
+ try {
440
+ const refreshed = await refreshConversationToken(
441
+ BASE_URL,
442
+ CONVERSATION_ID,
443
+ CONVERSATION_TOKEN,
444
+ auth.client_fingerprint,
445
+ );
446
+ // SSE connection: the server accepts the new token immediately on
447
+ // subsequent requests, and any in-flight fetch still has the old
448
+ // token attached at the header level — the server keeps the open
449
+ // stream alive on the old token until its natural close. Safe to
450
+ // overwrite the module variable here.
451
+ CONVERSATION_TOKEN = refreshed.conversation_token;
452
+ currentExpiresAt = refreshed.expires_at;
453
+ return { expiresAt: refreshed.expires_at };
454
+ } catch (err) {
455
+ const retryable =
456
+ (err instanceof RefreshHttpError && err.retryable) ||
457
+ err instanceof RefreshNetworkError;
458
+ if (!retryable || attempt === MAX_ATTEMPTS) throw err;
459
+ // Bound retries by token validity: never sleep past expiry, leave
460
+ // a small headroom so the post-sleep request has time to land.
461
+ const proposedDelay = Math.min(30 * 2 ** (attempt - 1), 120);
462
+ const remaining = currentExpiresAt - Date.now() / 1000;
463
+ if (remaining - proposedDelay < HEADROOM_SECONDS) throw err;
464
+ process.stderr.write(
465
+ `[viber-channel] token refresh attempt ${attempt} failed (${String(err)}); retry in ${proposedDelay}s (${Math.round(remaining)}s until expiry)\n`,
466
+ );
467
+ await new Promise((r) => setTimeout(r, proposedDelay * 1000));
468
+ }
469
+ }
470
+ // Unreachable — the loop above either returns the refreshed state or
471
+ // throws on attempt === MAX_ATTEMPTS.
472
+ throw new Error("token refresh: retry loop exhausted without resolution");
473
+ },
474
+ async (err) => {
475
+ process.stderr.write(`[viber-channel] token refresh failed: ${String(err)}\n`);
476
+ // We don't call scheduler.cancel() here — we're inside the scheduler's
477
+ // failure path, and handleConversationTokenExpired ends the process anyway.
478
+ await handleConversationTokenExpired(mcp);
479
+ },
480
+ validLead !== undefined ? { leadSeconds: validLead } : undefined,
481
+ );
482
+ scheduler.start(minted.expires_at);
379
483
  } catch (err) {
380
484
  if (err instanceof ReverifyRequiredError) {
381
485
  await mcp.notification({
@@ -529,6 +633,7 @@ async function sseLoop(): Promise<void> {
529
633
 
530
634
  case "stop":
531
635
  process.stderr.write(`[viber-channel] Received stop signal, exiting.\n`);
636
+ scheduler?.cancel();
532
637
  releaseLock();
533
638
  process.exit(0);
534
639
  break;