@pharaoh-so/mcp 0.3.21 → 0.3.23

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/dist/auth.d.ts CHANGED
@@ -12,6 +12,15 @@ export interface TokenResponse {
12
12
  access_token: string;
13
13
  token_type: string;
14
14
  expires_in: number;
15
+ /**
16
+ * Refresh token for silent access-token renewal. Present on all new sessions
17
+ * (server at src/web/routes/agent-auth.ts). Optional at the type level only
18
+ * for backward compat with hypothetical older servers — if missing, the proxy
19
+ * falls through to the full re-auth path on expiry.
20
+ */
21
+ refresh_token?: string;
22
+ /** Refresh token TTL in seconds. */
23
+ refresh_token_expires_in?: number;
15
24
  sse_url: string;
16
25
  github_login?: string;
17
26
  tenant_name?: string;
@@ -19,17 +28,65 @@ export interface TokenResponse {
19
28
  provisional?: boolean;
20
29
  install_url?: string;
21
30
  }
31
+ /**
32
+ * Result of a single poll against /device/token.
33
+ *
34
+ * This type is the contract between the state-machine gateway (gateway.ts),
35
+ * the blocking interactive poller (pollForToken), and the test harness. It
36
+ * collapses HTTP details into the four lifecycle outcomes callers actually
37
+ * need to branch on, plus a network_error bucket so callers can distinguish
38
+ * "server said no" from "couldn't reach server."
39
+ */
40
+ export type PollResult = {
41
+ status: "pending";
42
+ slowDown?: boolean;
43
+ } | {
44
+ status: "authorized";
45
+ token: TokenResponse;
46
+ } | {
47
+ status: "expired";
48
+ } | {
49
+ status: "denied";
50
+ error: string;
51
+ } | {
52
+ status: "network_error";
53
+ error: string;
54
+ };
22
55
  /**
23
56
  * Request a device code from the Pharaoh server (RFC 8628 §3.1).
24
57
  * Returns the device code, user code, and verification URIs.
25
58
  */
26
59
  export declare function requestDeviceCode(baseUrl: string): Promise<DeviceCodeResponse>;
60
+ /**
61
+ * Perform one POST to /device/token. No loop, no sleep — maps HTTP responses
62
+ * into a PollResult discriminated union.
63
+ *
64
+ * Called in two shapes:
65
+ * - pollForToken() (TTY path): loops with interval+jitter until terminal state.
66
+ * - gateway.handleToolCall() (proxy path): called once per tool call, gated
67
+ * by a last_poll_at check against the server's interval.
68
+ *
69
+ * Note: slow_down is mapped to "pending" — the caller's pacing logic is what
70
+ * responds to it. pollForToken bumps its local interval; gateway relies on the
71
+ * server-specified interval it already tracks. This keeps pollOnce stateless.
72
+ */
73
+ export declare function pollOnce(baseUrl: string, deviceCode: string): Promise<PollResult>;
27
74
  /**
28
75
  * Poll for device authorization completion (RFC 8628 §3.5).
29
76
  * Blocks until the user authorizes, the code expires, or an error occurs.
30
77
  * Adds 1s jitter to the server-specified interval to avoid thundering herd.
31
78
  */
32
79
  export declare function pollForToken(baseUrl: string, deviceCode: string, interval: number): Promise<TokenResponse>;
80
+ /**
81
+ * Open a URL in the user's default browser.
82
+ * Uses platform-specific commands; fails silently if unavailable.
83
+ *
84
+ * Exported for gateway.ts — the state machine fires this once per device code
85
+ * to pre-fill the activation URL in the user's browser. Silent-on-failure is
86
+ * critical for headless environments: a missing `open`/`xdg-open` binary must
87
+ * not crash the proxy.
88
+ */
89
+ export declare function openBrowser(url: string): void;
33
90
  /**
34
91
  * Print the device activation prompt to stderr.
35
92
  * Shows the user code and verification URI in a visible box,
package/dist/auth.js CHANGED
@@ -19,48 +19,94 @@ export async function requestDeviceCode(baseUrl) {
19
19
  }
20
20
  return (await res.json());
21
21
  }
22
+ /**
23
+ * Perform one POST to /device/token. No loop, no sleep — maps HTTP responses
24
+ * into a PollResult discriminated union.
25
+ *
26
+ * Called in two shapes:
27
+ * - pollForToken() (TTY path): loops with interval+jitter until terminal state.
28
+ * - gateway.handleToolCall() (proxy path): called once per tool call, gated
29
+ * by a last_poll_at check against the server's interval.
30
+ *
31
+ * Note: slow_down is mapped to "pending" — the caller's pacing logic is what
32
+ * responds to it. pollForToken bumps its local interval; gateway relies on the
33
+ * server-specified interval it already tracks. This keeps pollOnce stateless.
34
+ */
35
+ export async function pollOnce(baseUrl, deviceCode) {
36
+ const grantType = "urn:ietf:params:oauth:grant-type:device_code";
37
+ let res;
38
+ try {
39
+ res = await fetch(`${baseUrl}/device/token`, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ device_code: deviceCode, grant_type: grantType }),
43
+ });
44
+ }
45
+ catch (err) {
46
+ return {
47
+ status: "network_error",
48
+ error: err instanceof Error ? err.message : String(err),
49
+ };
50
+ }
51
+ if (res.ok) {
52
+ const token = (await res.json());
53
+ return { status: "authorized", token };
54
+ }
55
+ const body = (await res.json().catch(() => ({ error: "unknown" })));
56
+ if (body.error === "authorization_pending") {
57
+ return { status: "pending" };
58
+ }
59
+ if (body.error === "slow_down") {
60
+ return { status: "pending", slowDown: true };
61
+ }
62
+ if (body.error === "expired_token") {
63
+ return { status: "expired" };
64
+ }
65
+ if (body.error === "access_denied") {
66
+ return { status: "denied", error: "Authorization was denied." };
67
+ }
68
+ return {
69
+ status: "denied",
70
+ error: `Device token error: ${body.error} — ${body.error_description ?? ""}`,
71
+ };
72
+ }
22
73
  /**
23
74
  * Poll for device authorization completion (RFC 8628 §3.5).
24
75
  * Blocks until the user authorizes, the code expires, or an error occurs.
25
76
  * Adds 1s jitter to the server-specified interval to avoid thundering herd.
26
77
  */
27
78
  export async function pollForToken(baseUrl, deviceCode, interval) {
28
- const grantType = "urn:ietf:params:oauth:grant-type:device_code";
79
+ let currentInterval = interval;
29
80
  while (true) {
30
81
  // Wait interval + 0–1s jitter before each poll
31
82
  const jitterMs = Math.random() * 1000;
32
- await sleep(interval * 1000 + jitterMs);
33
- const res = await fetch(`${baseUrl}/device/token`, {
34
- method: "POST",
35
- headers: { "Content-Type": "application/json" },
36
- body: JSON.stringify({ device_code: deviceCode, grant_type: grantType }),
37
- });
38
- if (res.ok) {
39
- return (await res.json());
40
- }
41
- const body = (await res.json().catch(() => ({ error: "unknown" })));
42
- if (body.error === "authorization_pending") {
43
- continue;
44
- }
45
- if (body.error === "slow_down") {
46
- // RFC 8628 §3.5: increase interval by 5 seconds
47
- interval += 5;
48
- continue;
49
- }
50
- if (body.error === "expired_token") {
83
+ await sleep(currentInterval * 1000 + jitterMs);
84
+ const result = await pollOnce(baseUrl, deviceCode);
85
+ if (result.status === "authorized")
86
+ return result.token;
87
+ if (result.status === "expired") {
51
88
  throw new Error("Device code expired. Please try again.");
52
89
  }
53
- if (body.error === "access_denied") {
54
- throw new Error("Authorization was denied.");
55
- }
56
- throw new Error(`Device token error: ${body.error} — ${body.error_description ?? ""}`);
90
+ if (result.status === "denied")
91
+ throw new Error(result.error);
92
+ if (result.status === "network_error")
93
+ throw new Error(result.error);
94
+ // result.status === "pending". RFC 8628 §3.5: only bump interval on
95
+ // slow_down (authorization_pending is the normal polling response).
96
+ if (result.slowDown)
97
+ currentInterval += 5;
57
98
  }
58
99
  }
59
100
  /**
60
101
  * Open a URL in the user's default browser.
61
102
  * Uses platform-specific commands; fails silently if unavailable.
103
+ *
104
+ * Exported for gateway.ts — the state machine fires this once per device code
105
+ * to pre-fill the activation URL in the user's browser. Silent-on-failure is
106
+ * critical for headless environments: a missing `open`/`xdg-open` binary must
107
+ * not crash the proxy.
62
108
  */
63
- function openBrowser(url) {
109
+ export function openBrowser(url) {
64
110
  if (process.platform === "darwin") {
65
111
  execFile("open", [url], () => { });
66
112
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Backend interface — the abstraction the ProxyServer delegates tool calls to.
3
+ *
4
+ * Two concrete implementations:
5
+ *
6
+ * 1. AuthRequiredBackend — advertises a single tool (`get_codebase_map`) that
7
+ * turns every invocation into a device-flow state step. Used while the
8
+ * proxy is waiting for the user to authorize.
9
+ *
10
+ * 2. SseProxyBackend (returned by createSseBackend) — wraps an MCP Client
11
+ * connected to the remote Pharaoh SSE server. Forwards list/call requests
12
+ * verbatim and preserves the ghost-session reconnect fix from proxy.ts.
13
+ *
14
+ * ProxyServer swaps between the two by reassigning a reference. The SDK
15
+ * resolves each tool call's handler at request-arrival time, so in-flight
16
+ * calls complete against whichever backend started them.
17
+ */
18
+ import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
19
+ import type { DeviceFlowGateway } from "./gateway.js";
20
+ /** The contract ProxyServer uses to delegate tool handling. */
21
+ export interface Backend {
22
+ readonly name: "auth-required" | "sse";
23
+ listTools(): Promise<Tool[]>;
24
+ callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult>;
25
+ /** Optional one-time startup (e.g. connect SSE). Idempotent. */
26
+ start?(): Promise<void>;
27
+ /** Optional teardown (e.g. close SSE). Idempotent. */
28
+ stop?(): Promise<void>;
29
+ }
30
+ /**
31
+ * Minimal pre-auth catalog. Single tool — matches Pharaoh's "call this first"
32
+ * protocol (CLAUDE.md, skills), so the pre-auth surface mirrors the first
33
+ * tool most agents try. Keeping this at 1 means the swap to the real catalog
34
+ * fires a clean visible sendToolListChanged delta.
35
+ *
36
+ * If you add tools here, sync with src/mcp/tool-registry.ts in the main
37
+ * package — descriptions should match so agents get consistent behavior
38
+ * across the pre-auth / authorized transition.
39
+ */
40
+ export declare const AUTH_REQUIRED_TOOLS: Tool[];
41
+ /**
42
+ * Pre-auth backend. Every tool call triggers a device-flow state step via the
43
+ * gateway; returns a structured auth-required response. Once the gateway
44
+ * reports the user has authorized (handleToolCall returns null), the caller
45
+ * (ProxyServer) swaps to SseProxyBackend — this backend does NOT swap itself.
46
+ */
47
+ export declare class AuthRequiredBackend implements Backend {
48
+ private readonly gateway;
49
+ readonly name: "auth-required";
50
+ constructor(gateway: DeviceFlowGateway);
51
+ listTools(): Promise<Tool[]>;
52
+ callTool(_name: string, _args: Record<string, unknown>): Promise<CallToolResult>;
53
+ }
54
+ /**
55
+ * Real SSE-backed backend. Owns an MCP Client + SSEClientTransport and a
56
+ * reconnect loop. Forwards listTools/callTool to the remote server.
57
+ *
58
+ * Reconnect semantics (preserved verbatim from the old startProxy):
59
+ * EventSource's built-in auto-reconnect creates ghost sessions on the
60
+ * server (new auth, new session ID per 3s reconnect). On any non-fatal
61
+ * error we explicitly close() the transport to kill EventSource, then
62
+ * let this loop create a fresh transport for the next attempt. 5 retries
63
+ * (2s, 4s, 8s, 16s, 32s) before giving up.
64
+ */
65
+ declare class SseProxyBackend implements Backend {
66
+ private readonly sseUrl;
67
+ private readonly token;
68
+ readonly name: "sse";
69
+ private client;
70
+ private transport;
71
+ private stopped;
72
+ private reconnectPromise;
73
+ constructor(sseUrl: string, token: string);
74
+ start(): Promise<void>;
75
+ /**
76
+ * Expose the long-running reconnect loop so a supervisor can await it.
77
+ * Session C uses this to bubble TokenExpiredError / TenantSuspendedError
78
+ * up to the same handler that used to wrap `startProxy`.
79
+ */
80
+ waitForExit(): Promise<void>;
81
+ private connectOnce;
82
+ /** Wait for the current transport to close, then decide what to do. */
83
+ private waitForClose;
84
+ private reconnectAfter;
85
+ private runReconnectLoop;
86
+ listTools(): Promise<Tool[]>;
87
+ callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult>;
88
+ stop(): Promise<void>;
89
+ }
90
+ /**
91
+ * Public factory — ProxyServer / Session C build SSE backends via this.
92
+ * Keeps the SseProxyBackend class private so callers can't accidentally
93
+ * depend on its internal reconnect state.
94
+ */
95
+ export declare function createSseBackend(sseUrl: string, token: string): SseProxyBackend;
96
+ export {};
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Backend interface — the abstraction the ProxyServer delegates tool calls to.
3
+ *
4
+ * Two concrete implementations:
5
+ *
6
+ * 1. AuthRequiredBackend — advertises a single tool (`get_codebase_map`) that
7
+ * turns every invocation into a device-flow state step. Used while the
8
+ * proxy is waiting for the user to authorize.
9
+ *
10
+ * 2. SseProxyBackend (returned by createSseBackend) — wraps an MCP Client
11
+ * connected to the remote Pharaoh SSE server. Forwards list/call requests
12
+ * verbatim and preserves the ghost-session reconnect fix from proxy.ts.
13
+ *
14
+ * ProxyServer swaps between the two by reassigning a reference. The SDK
15
+ * resolves each tool call's handler at request-arrival time, so in-flight
16
+ * calls complete against whichever backend started them.
17
+ */
18
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
19
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
20
+ import { classifySSEError, TenantSuspendedError, TokenExpiredError } from "./proxy.js";
21
+ import { getPackageVersion } from "./version.js";
22
+ /**
23
+ * Minimal pre-auth catalog. Single tool — matches Pharaoh's "call this first"
24
+ * protocol (CLAUDE.md, skills), so the pre-auth surface mirrors the first
25
+ * tool most agents try. Keeping this at 1 means the swap to the real catalog
26
+ * fires a clean visible sendToolListChanged delta.
27
+ *
28
+ * If you add tools here, sync with src/mcp/tool-registry.ts in the main
29
+ * package — descriptions should match so agents get consistent behavior
30
+ * across the pre-auth / authorized transition.
31
+ */
32
+ export const AUTH_REQUIRED_TOOLS = [
33
+ {
34
+ name: "get_codebase_map",
35
+ description: "Pharaoh — call this FIRST to understand the codebase structure. " +
36
+ "Requires authentication; calling this tool will surface the auth URL.",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ repo: {
41
+ type: "string",
42
+ description: "Optional repo slug (owner/name) to map.",
43
+ },
44
+ },
45
+ },
46
+ },
47
+ ];
48
+ /**
49
+ * Pre-auth backend. Every tool call triggers a device-flow state step via the
50
+ * gateway; returns a structured auth-required response. Once the gateway
51
+ * reports the user has authorized (handleToolCall returns null), the caller
52
+ * (ProxyServer) swaps to SseProxyBackend — this backend does NOT swap itself.
53
+ */
54
+ export class AuthRequiredBackend {
55
+ gateway;
56
+ name = "auth-required";
57
+ constructor(gateway) {
58
+ this.gateway = gateway;
59
+ }
60
+ async listTools() {
61
+ return AUTH_REQUIRED_TOOLS;
62
+ }
63
+ async callTool(_name, _args) {
64
+ const response = await this.gateway.handleToolCall();
65
+ if (response === null) {
66
+ // Credentials just became valid. Return a success result so the
67
+ // agent's current tool call doesn't error out. ProxyServer checks
68
+ // gateway.hasValidCredentials() after each call and swaps backends.
69
+ return {
70
+ content: [
71
+ {
72
+ type: "text",
73
+ text: "Pharaoh: authenticated. Call this tool again to get the codebase map.",
74
+ },
75
+ ],
76
+ isError: false,
77
+ };
78
+ }
79
+ // The gateway response shape is CallToolResult-compatible (content,
80
+ // isError, _meta) — the MCP SDK accepts it verbatim.
81
+ return response;
82
+ }
83
+ }
84
+ /** Backoff schedule for reconnect attempts (milliseconds). */
85
+ const BACKOFF_MS = [2000, 4000, 8000, 16000, 32000];
86
+ /**
87
+ * Build the SSEClientTransport with Bearer token auth. Injects the
88
+ * Authorization header into both the SSE connection (via custom fetch) and
89
+ * POST requests (via requestInit).
90
+ */
91
+ function createSSETransport(sseUrl, token) {
92
+ const authHeader = `Bearer ${token}`;
93
+ return new SSEClientTransport(new URL(sseUrl), {
94
+ eventSourceInit: {
95
+ fetch: (url, init) => {
96
+ const h = new Headers(init.headers);
97
+ h.set("Authorization", authHeader);
98
+ return fetch(url, { ...init, headers: h });
99
+ },
100
+ },
101
+ requestInit: {
102
+ headers: { Authorization: authHeader },
103
+ },
104
+ });
105
+ }
106
+ function sleep(ms) {
107
+ return new Promise((resolve) => setTimeout(resolve, ms));
108
+ }
109
+ /**
110
+ * Real SSE-backed backend. Owns an MCP Client + SSEClientTransport and a
111
+ * reconnect loop. Forwards listTools/callTool to the remote server.
112
+ *
113
+ * Reconnect semantics (preserved verbatim from the old startProxy):
114
+ * EventSource's built-in auto-reconnect creates ghost sessions on the
115
+ * server (new auth, new session ID per 3s reconnect). On any non-fatal
116
+ * error we explicitly close() the transport to kill EventSource, then
117
+ * let this loop create a fresh transport for the next attempt. 5 retries
118
+ * (2s, 4s, 8s, 16s, 32s) before giving up.
119
+ */
120
+ class SseProxyBackend {
121
+ sseUrl;
122
+ token;
123
+ name = "sse";
124
+ client = null;
125
+ transport = null;
126
+ stopped = false;
127
+ reconnectPromise = null;
128
+ constructor(sseUrl, token) {
129
+ this.sseUrl = sseUrl;
130
+ this.token = token;
131
+ }
132
+ async start() {
133
+ if (this.client)
134
+ return; // idempotent
135
+ await this.connectOnce();
136
+ // Keep the reconnect loop alive in the background. It resolves when
137
+ // the SSE stream closes cleanly; on typed errors (401/403) it throws.
138
+ this.reconnectPromise = this.runReconnectLoop();
139
+ // Surface typed errors at the top level so the CLI (or future
140
+ // supervisor) can act on them.
141
+ this.reconnectPromise.catch(() => {
142
+ /* error is retrieved via waitForExit() when needed */
143
+ });
144
+ }
145
+ /**
146
+ * Expose the long-running reconnect loop so a supervisor can await it.
147
+ * Session C uses this to bubble TokenExpiredError / TenantSuspendedError
148
+ * up to the same handler that used to wrap `startProxy`.
149
+ */
150
+ waitForExit() {
151
+ return this.reconnectPromise ?? Promise.resolve();
152
+ }
153
+ async connectOnce() {
154
+ this.transport = createSSETransport(this.sseUrl, this.token);
155
+ this.client = new Client({ name: "pharaoh-proxy", version: getPackageVersion() }, { capabilities: {} });
156
+ await this.client.connect(this.transport);
157
+ }
158
+ /** Wait for the current transport to close, then decide what to do. */
159
+ waitForClose() {
160
+ return new Promise((resolve, reject) => {
161
+ if (!this.transport) {
162
+ resolve();
163
+ return;
164
+ }
165
+ const sse = this.transport;
166
+ sse.onclose = () => resolve();
167
+ sse.onerror = (err) => {
168
+ try {
169
+ classifySSEError(err);
170
+ }
171
+ catch (typed) {
172
+ reject(typed);
173
+ return;
174
+ }
175
+ // Non-fatal — explicitly close the transport to kill
176
+ // EventSource's built-in auto-reconnect. Without this,
177
+ // EventSource reconnects every ~3s, creating ghost sessions
178
+ // on the server (100+ auth successes, zero tool calls
179
+ // completing). close() fires onclose which resolves this
180
+ // promise, handing control back to the backoff loop.
181
+ sse.close().catch(() => { });
182
+ };
183
+ });
184
+ }
185
+ async reconnectAfter(attempt) {
186
+ const delay = BACKOFF_MS[attempt - 1];
187
+ process.stderr.write(`Pharaoh: reconnecting in ${delay / 1000}s (attempt ${attempt}/${BACKOFF_MS.length})...\n`);
188
+ await sleep(delay);
189
+ await this.connectOnce();
190
+ process.stderr.write("Pharaoh: reconnected\n");
191
+ }
192
+ async runReconnectLoop() {
193
+ process.stderr.write("Pharaoh: connected\n");
194
+ let attempt = 0;
195
+ while (!this.stopped) {
196
+ try {
197
+ await this.waitForClose();
198
+ attempt++;
199
+ if (attempt > BACKOFF_MS.length) {
200
+ process.stderr.write("Pharaoh: max reconnect attempts reached — exiting\n");
201
+ throw new Error("SSE connection failed after maximum retries");
202
+ }
203
+ await this.reconnectAfter(attempt);
204
+ attempt = 0;
205
+ }
206
+ catch (err) {
207
+ if (err instanceof TokenExpiredError || err instanceof TenantSuspendedError) {
208
+ throw err;
209
+ }
210
+ attempt++;
211
+ if (attempt > BACKOFF_MS.length) {
212
+ throw err instanceof Error
213
+ ? err
214
+ : new Error("SSE connection failed after maximum retries");
215
+ }
216
+ }
217
+ }
218
+ }
219
+ async listTools() {
220
+ if (!this.client)
221
+ throw new Error("SseProxyBackend.listTools() before start()");
222
+ const res = await this.client.listTools();
223
+ return res.tools;
224
+ }
225
+ async callTool(name, args) {
226
+ if (!this.client)
227
+ throw new Error("SseProxyBackend.callTool() before start()");
228
+ const res = await this.client.callTool({ name, arguments: args });
229
+ return res;
230
+ }
231
+ async stop() {
232
+ this.stopped = true;
233
+ if (this.transport) {
234
+ await this.transport.close().catch(() => { });
235
+ this.transport = null;
236
+ }
237
+ if (this.client) {
238
+ await this.client.close().catch(() => { });
239
+ this.client = null;
240
+ }
241
+ }
242
+ }
243
+ /**
244
+ * Public factory — ProxyServer / Session C build SSE backends via this.
245
+ * Keeps the SseProxyBackend class private so callers can't accidentally
246
+ * depend on its internal reconnect state.
247
+ */
248
+ export function createSseBackend(sseUrl, token) {
249
+ return new SseProxyBackend(sseUrl, token);
250
+ }
@@ -1,12 +1,21 @@
1
- /** Current credential schema version — bump when the format changes. */
2
- declare const SCHEMA_VERSION = 1;
1
+ /**
2
+ * Current credential schema version. v2 adds refresh_token + refresh_expires_at
3
+ * so the proxy can silently renew access tokens. v1 files parse as null, which
4
+ * forces one re-auth on upgrade — unavoidable because v1 never had a refresh
5
+ * token to upgrade from.
6
+ */
7
+ declare const SCHEMA_VERSION = 2;
3
8
  /** Stored credential shape. */
4
9
  export interface Credentials {
5
10
  version: typeof SCHEMA_VERSION;
6
11
  access_token: string;
7
- /** ISO 8601 timestamp when the token expires. */
12
+ /** Refresh token used by refreshIfNeeded() to renew the access token. */
13
+ refresh_token: string;
14
+ /** ISO 8601 timestamp when the access token expires. */
8
15
  expires_at: string;
9
- /** Original TTL in seconds (from the token response). */
16
+ /** ISO 8601 timestamp when the refresh token itself expires. */
17
+ refresh_expires_at: string;
18
+ /** Original access token TTL in seconds (from the token response). */
10
19
  expires_in: number;
11
20
  sse_url: string;
12
21
  github_login: string | null;
@@ -15,7 +24,8 @@ export interface Credentials {
15
24
  }
16
25
  /**
17
26
  * Read and parse stored credentials. Returns null if missing, unreadable,
18
- * malformed, or wrong schema version.
27
+ * malformed, or wrong schema version (v1 credentials will return null here,
28
+ * forcing one last re-auth on upgrade to v2).
19
29
  */
20
30
  export declare function readCredentials(path?: string): Credentials | null;
21
31
  /**
@@ -26,8 +36,13 @@ export declare function writeCredentials(creds: Credentials, path?: string): voi
26
36
  /** Delete the credential file. No-op if it doesn't exist. */
27
37
  export declare function deleteCredentials(path?: string): void;
28
38
  /**
29
- * Check if credentials are expired or within 10% of expiry.
39
+ * Check if the access token is expired or within 10% of expiry.
30
40
  * The 10% buffer prevents connecting with a token that will expire mid-session.
31
41
  */
32
42
  export declare function isExpired(creds: Credentials): boolean;
43
+ /**
44
+ * Check if the refresh token itself has expired. When this returns true, even
45
+ * refresh cannot rescue the session — the user must re-authenticate.
46
+ */
47
+ export declare function isRefreshExpired(creds: Credentials): boolean;
33
48
  export {};
@@ -5,13 +5,19 @@
5
5
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
- /** Current credential schema version — bump when the format changes. */
9
- const SCHEMA_VERSION = 1;
8
+ /**
9
+ * Current credential schema version. v2 adds refresh_token + refresh_expires_at
10
+ * so the proxy can silently renew access tokens. v1 files parse as null, which
11
+ * forces one re-auth on upgrade — unavoidable because v1 never had a refresh
12
+ * token to upgrade from.
13
+ */
14
+ const SCHEMA_VERSION = 2;
10
15
  /** Default credential file path. */
11
16
  const CREDENTIALS_PATH = join(homedir(), ".pharaoh", "credentials.json");
12
17
  /**
13
18
  * Read and parse stored credentials. Returns null if missing, unreadable,
14
- * malformed, or wrong schema version.
19
+ * malformed, or wrong schema version (v1 credentials will return null here,
20
+ * forcing one last re-auth on upgrade to v2).
15
21
  */
16
22
  export function readCredentials(path = CREDENTIALS_PATH) {
17
23
  try {
@@ -46,7 +52,7 @@ export function deleteCredentials(path = CREDENTIALS_PATH) {
46
52
  }
47
53
  }
48
54
  /**
49
- * Check if credentials are expired or within 10% of expiry.
55
+ * Check if the access token is expired or within 10% of expiry.
50
56
  * The 10% buffer prevents connecting with a token that will expire mid-session.
51
57
  */
52
58
  export function isExpired(creds) {
@@ -58,6 +64,16 @@ export function isExpired(creds) {
58
64
  const bufferMs = totalTtlMs * 0.1;
59
65
  return now >= expiresAt - bufferMs;
60
66
  }
67
+ /**
68
+ * Check if the refresh token itself has expired. When this returns true, even
69
+ * refresh cannot rescue the session — the user must re-authenticate.
70
+ */
71
+ export function isRefreshExpired(creds) {
72
+ const refreshExpiresAt = new Date(creds.refresh_expires_at).getTime();
73
+ if (Number.isNaN(refreshExpiresAt))
74
+ return true;
75
+ return Date.now() >= refreshExpiresAt;
76
+ }
61
77
  /** Type guard for valid credential objects. */
62
78
  function isValidCredentials(value) {
63
79
  if (typeof value !== "object" || value === null)
@@ -65,7 +81,9 @@ function isValidCredentials(value) {
65
81
  const obj = value;
66
82
  return (obj.version === SCHEMA_VERSION &&
67
83
  typeof obj.access_token === "string" &&
84
+ typeof obj.refresh_token === "string" &&
68
85
  typeof obj.expires_at === "string" &&
86
+ typeof obj.refresh_expires_at === "string" &&
69
87
  typeof obj.expires_in === "number" &&
70
88
  typeof obj.sse_url === "string" &&
71
89
  Array.isArray(obj.repos));