@1presence/bridge 0.73.0 → 0.75.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/README.md CHANGED
@@ -45,7 +45,7 @@ supported (the second displaces the first).
45
45
 
46
46
  ## How it works
47
47
 
48
- The bridge connects outbound to the 1Presence gateway as a persistent WebSocket. When you send a message from the app, the gateway relays it to the bridge, which spawns a `claude` subprocess with your personal system prompt and all 1Presence tools (vault, Gmail, Drive, Calendar, MemPalace, and more) wired in via MCP. Responses stream back in real time.
48
+ The bridge connects outbound to the 1Presence gateway as a persistent WebSocket. When you send a message from the app, the gateway relays it to the bridge, which spawns a `claude` subprocess with your personal system prompt and all 1Presence tools (vault, Gmail, Drive, Calendar, memory, and more) wired in via MCP. Responses stream back in real time.
49
49
 
50
50
  Conversations are stateful — the bridge maps each 1Presence conversation to a Claude Code session ID so context is preserved across messages.
51
51
 
@@ -5,17 +5,7 @@ export function makeBridgeAccumulator() {
5
5
  toolResults: {},
6
6
  turns: [],
7
7
  };
8
- // A new API turn bucket only opens when a user event (tool_results) has
9
- // arrived since the last assistant event — mirrors the gateway translator
10
- // logic. The CLI splits a single model emission's text and tool_use blocks
11
- // into separate `{type:'assistant'}` events; without this guard each split
12
- // creates a stored assistant message and produces consecutive same-role
13
- // rows that the read-side merger has to repair. Init true so the very first
14
- // assistant event opens turn 0.
15
8
  let sawUserSinceLastAssistant = true;
16
- // Returns the current open API-turn bucket, creating one lazily if a text
17
- // event arrives before any `{type:'assistant'}` event (shouldn't normally
18
- // happen with Claude Code's stream-json, but keep it robust).
19
9
  function currentTurn() {
20
10
  if (state.turns.length === 0)
21
11
  state.turns.push({ text: '', toolUseIds: [] });
@@ -24,9 +14,7 @@ export function makeBridgeAccumulator() {
24
14
  function appendText(text) {
25
15
  const turn = currentTurn();
26
16
  turn.text += text;
27
- // Mirror to the flat string with `\n\n` between turns for convenience.
28
17
  if (state.assistantText && state.turns.length > 1 && turn.text === text) {
29
- // First text emission of a new turn — separate from prior turn.
30
18
  state.assistantText += '\n\n';
31
19
  }
32
20
  state.assistantText += text;
@@ -41,10 +29,6 @@ export function makeBridgeAccumulator() {
41
29
  return;
42
30
  }
43
31
  if (type === 'assistant') {
44
- // Only open a fresh bucket when a user event has arrived since the
45
- // last assistant event. Otherwise treat this as a continuation of
46
- // the current API turn (CLI splits text and tool_use into separate
47
- // assistant events within one model emission).
48
32
  if (sawUserSinceLastAssistant) {
49
33
  state.turns.push({ text: '', toolUseIds: [] });
50
34
  sawUserSinceLastAssistant = false;
@@ -78,7 +62,6 @@ export function makeBridgeAccumulator() {
78
62
  return;
79
63
  }
80
64
  if (type === 'user') {
81
- // Flip the flag so the next assistant event opens a new API turn.
82
65
  sawUserSinceLastAssistant = true;
83
66
  const msg = event['message'];
84
67
  const content = msg?.['content'];
package/dist/auth.js CHANGED
@@ -4,12 +4,8 @@ import { homedir } from 'os';
4
4
  import { join } from 'path';
5
5
  import { exec } from 'child_process';
6
6
  import { randomBytes, timingSafeEqual } from 'crypto';
7
- // Auth lives only in process memory. Earlier versions persisted tokens to
8
- // ~/.1presence/auth.json; remove any leftover file on startup so a stale,
9
- // permission-bearing token can't survive a bridge restart.
10
7
  const LEGACY_AUTH_FILE = join(homedir(), '.1presence', 'auth.json');
11
8
  rmSync(LEGACY_AUTH_FILE, { force: true });
12
- // ─── JWT helpers ──────────────────────────────────────────────────────────────
13
9
  function parseJwt(token) {
14
10
  try {
15
11
  const payload = token.split('.')[1];
@@ -24,7 +20,6 @@ export function isTokenValid(token) {
24
20
  const { exp } = parseJwt(token);
25
21
  if (!exp)
26
22
  return false;
27
- // Require at least 5 minutes of validity remaining
28
23
  return exp > Math.floor(Date.now() / 1000) + 300;
29
24
  }
30
25
  function uidFromToken(token) {
@@ -33,7 +28,6 @@ function uidFromToken(token) {
33
28
  function emailFromToken(token) {
34
29
  return parseJwt(token).email;
35
30
  }
36
- // ─── Browser auth flow ────────────────────────────────────────────────────────
37
31
  function openBrowser(url) {
38
32
  const platform = process.platform;
39
33
  const cmd = platform === 'darwin' ? `open "${url}"`
@@ -47,18 +41,6 @@ function openBrowser(url) {
47
41
  export class AuthCancelledError extends Error {
48
42
  constructor() { super('Sign-in cancelled — the browser tab was closed.'); }
49
43
  }
50
- /**
51
- * Applies the localhost auth server's CORS headers, scoped to the legitimate
52
- * PWA origin. Exported so the Private Network Access behaviour stays under test.
53
- *
54
- * The `Access-Control-Allow-Private-Network` reflection is load-bearing: when
55
- * the HTTPS PWA (a public/secure context) fetches this 127.0.0.1 server (a
56
- * private address), Chrome sends a CORS preflight carrying
57
- * `Access-Control-Request-Private-Network: true`. Without the matching allow
58
- * header, Chrome (PNA enforcement, ~v130+) blocks the request outright — even a
59
- * plain GET, even for localhost, which is exempt from mixed-content blocking —
60
- * and the PWA shows "Could not reach the bridge" though the server is up.
61
- */
62
44
  export function applyAuthCorsHeaders(req, res, pwaOrigin) {
63
45
  const reqOrigin = req.headers['origin'] ?? '';
64
46
  if (pwaOrigin && reqOrigin === pwaOrigin) {
@@ -74,10 +56,6 @@ export function applyAuthCorsHeaders(req, res, pwaOrigin) {
74
56
  function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
75
57
  return new Promise((resolve, reject) => {
76
58
  let resolved = false;
77
- // Per-launch nonce. Embedded in the auth URL the bridge prints/opens and
78
- // required on every request to this localhost server. Without it, a
79
- // malicious page in the user's browser could scan ephemeral ports during
80
- // the auth window and POST a forged token to hijack the bridge.
81
59
  const nonce = randomBytes(32).toString('base64url');
82
60
  const nonceBuf = Buffer.from(nonce, 'utf-8');
83
61
  function checkNonce(provided) {
@@ -88,7 +66,6 @@ function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
88
66
  return false;
89
67
  return timingSafeEqual(provBuf, nonceBuf);
90
68
  }
91
- // CORS allowlist scoped to the legitimate PWA origin only.
92
69
  const pwaOrigin = (() => {
93
70
  try {
94
71
  return new URL(pwaUrl).origin;
@@ -106,13 +83,11 @@ function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
106
83
  }
107
84
  const reqUrl = new URL(req.url ?? '/', 'http://localhost');
108
85
  const path = reqUrl.pathname;
109
- // Every request must carry the launch-specific nonce.
110
86
  if (!checkNonce(reqUrl.searchParams.get('nonce'))) {
111
87
  res.writeHead(403);
112
88
  res.end();
113
89
  return;
114
90
  }
115
- // Lets the PWA verify the bridge is still listening before POSTing the token.
116
91
  if (req.method === 'GET' && (path === '/' || path === '')) {
117
92
  res.writeHead(200, { 'Content-Type': 'text/plain' });
118
93
  res.end('1Presence bridge waiting for sign-in');
@@ -123,8 +98,6 @@ function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
123
98
  res.end();
124
99
  return;
125
100
  }
126
- // Status beacon from the PWA — used so we exit early when the user closes
127
- // the auth tab before signing in (sendBeacon path).
128
101
  if (path === '/status') {
129
102
  const event = reqUrl.searchParams.get('event');
130
103
  res.writeHead(204);
@@ -181,8 +154,6 @@ function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
181
154
  }, 5 * 60 * 1000);
182
155
  });
183
156
  }
184
- // ─── Token refresh ────────────────────────────────────────────────────────────
185
- // Firebase web API key — public, safe to embed
186
157
  const FIREBASE_API_KEY = 'AIzaSyAz16A3eRIMhdLGF9ptsVsWZx9LjKeZwi8';
187
158
  async function refreshIdToken(refreshToken) {
188
159
  const res = await fetch(`https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`, {
@@ -197,47 +168,22 @@ async function refreshIdToken(refreshToken) {
197
168
  throw new Error('Token refresh returned no id_token');
198
169
  return data.id_token;
199
170
  }
200
- /** Headroom below which ensureFreshToken mints a new ID token at turn start.
201
- * Firebase ID tokens live 60 min; the token is baked into the MCP SSE config
202
- * header at spawn (writeMcpConfig) and reused by that connection for the whole
203
- * turn — it can't be re-tokened mid-turn without reconnecting the MCP server.
204
- * So a turn must START with enough headroom to outlive itself. At 10 min a turn
205
- * that began with ~12 min of life and ran 17 min lost its MCP OAuth AND its
206
- * save-turn token mid-flight ("MCP OAuth not configured" + save-turn 401 — see
207
- * the 2026-07-07 stuck-run bug). 30 min comfortably covers the 10-min bridge
208
- * stage wall-clock and any normal chat turn; near-full-hour refresh would churn
209
- * a token every turn for little gain. */
210
171
  const TOKEN_REFRESH_HEADROOM_SEC = 30 * 60;
211
- /** Returns auth with a fresh ID token. Refreshes in-memory if less than
212
- * TOKEN_REFRESH_HEADROOM_SEC of validity remains. */
213
172
  export async function ensureFreshToken(auth) {
214
173
  const { exp } = parseJwt(auth.token);
215
174
  if (exp && exp > Math.floor(Date.now() / 1000) + TOKEN_REFRESH_HEADROOM_SEC)
216
175
  return auth;
217
176
  if (!auth.refreshToken)
218
- return auth; // no refresh token, use as-is
177
+ return auth;
219
178
  const newToken = await refreshIdToken(auth.refreshToken);
220
179
  return { ...auth, token: newToken };
221
180
  }
222
- /**
223
- * Mints a fresh ID token unconditionally, ignoring local expiry. Used on the
224
- * reconnect path after the gateway has *rejected* our token (WS close 4001):
225
- * the gateway's verdict beats our own clock, so we always refresh rather than
226
- * trusting a token that still looks valid locally (e.g. clock skew). Returns
227
- * the refreshed auth, or null when there is no refresh token to mint from —
228
- * the caller must then fall back to an interactive re-sign-in. Throws only if
229
- * the refresh endpoint itself rejects (refresh token revoked/expired).
230
- */
231
181
  export async function forceRefreshToken(auth) {
232
182
  if (!auth.refreshToken)
233
183
  return null;
234
184
  const newToken = await refreshIdToken(auth.refreshToken);
235
185
  return { ...auth, token: newToken };
236
186
  }
237
- // ─── Public API ───────────────────────────────────────────────────────────────
238
- // No cache — every bridge launch goes through the browser flow. This means
239
- // permission revocations take effect on the next restart, and the PWA's
240
- // CliAuthPage no-permission screen is what users see if access is denied.
241
187
  export async function getValidAuth(gatewayUrl, pwaUrl) {
242
188
  console.log('Sign-in required.');
243
189
  return runBrowserAuthFlow(gatewayUrl, pwaUrl);