@open-webapp/drive-sync 0.5.6 → 0.6.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.
@@ -17,7 +17,11 @@ export function createGisFake() {
17
17
  const responseQueue = [];
18
18
  const popupErrorQueue = [];
19
19
  const popupClosedRaceQueue = [];
20
+ let silenceQueue = 0;
20
21
  const calls = [];
22
+ const codeResponseQueue = [];
23
+ const codeErrorQueue = [];
24
+ const codeCalls = [];
21
25
  let previousGoogle;
22
26
  let hadGoogle = false;
23
27
  function nextResponse() {
@@ -27,6 +31,45 @@ export function createGisFake() {
27
31
  // Default: a generic successful token response.
28
32
  return { access_token: 'fake-access-token', expires_in: 3600, scope: '' };
29
33
  }
34
+ function nextCodeResponse() {
35
+ const next = codeResponseQueue.shift();
36
+ if (next)
37
+ return next;
38
+ // Default: a generic successful auth-code response.
39
+ return { code: 'fake-auth-code' };
40
+ }
41
+ function initCodeClient(config) {
42
+ return {
43
+ requestCode() {
44
+ const hint = config.hint;
45
+ const scope = config.scope ?? '';
46
+ codeCalls.push({ scope, hint });
47
+ if (silenceQueue > 0) {
48
+ silenceQueue -= 1;
49
+ return;
50
+ }
51
+ const codeError = codeErrorQueue.shift();
52
+ if (codeError) {
53
+ const errorCallback = config.error_callback;
54
+ queueMicrotask(() => {
55
+ errorCallback?.({ type: codeError });
56
+ });
57
+ return;
58
+ }
59
+ const response = nextCodeResponse();
60
+ const callback = config.callback;
61
+ // Deliver asynchronously (microtask), matching the real GIS code
62
+ // client's callback-based, non-synchronous delivery.
63
+ queueMicrotask(() => {
64
+ if (response.error) {
65
+ callback?.({ error: response.error });
66
+ return;
67
+ }
68
+ callback?.(response);
69
+ });
70
+ },
71
+ };
72
+ }
30
73
  function initTokenClient(config) {
31
74
  return {
32
75
  requestAccessToken(overrideConfig) {
@@ -34,6 +77,10 @@ export function createGisFake() {
34
77
  const hint = overrideConfig?.hint ?? config.hint;
35
78
  const scope = overrideConfig?.scope ?? config.scope ?? '';
36
79
  calls.push({ prompt, hint, scope });
80
+ if (silenceQueue > 0) {
81
+ silenceQueue -= 1;
82
+ return;
83
+ }
37
84
  const popupClosedRace = popupClosedRaceQueue.shift();
38
85
  if (popupClosedRace) {
39
86
  const errorCallback = config.error_callback;
@@ -73,15 +120,25 @@ export function createGisFake() {
73
120
  }
74
121
  return {
75
122
  calls,
123
+ codeCalls,
76
124
  queueResponse(response) {
77
125
  responseQueue.push(response);
78
126
  },
127
+ queueCodeResponse(response) {
128
+ codeResponseQueue.push(response);
129
+ },
130
+ queueCodeError(type) {
131
+ codeErrorQueue.push(type);
132
+ },
79
133
  queuePopupError(type) {
80
134
  popupErrorQueue.push(type);
81
135
  },
82
136
  queuePopupClosedRace(response, delayMs) {
83
137
  popupClosedRaceQueue.push({ response, delayMs });
84
138
  },
139
+ queueSilence() {
140
+ silenceQueue += 1;
141
+ },
85
142
  install() {
86
143
  const w = globalThis;
87
144
  hadGoogle = Object.prototype.hasOwnProperty.call(w, 'google');
@@ -96,6 +153,7 @@ export function createGisFake() {
96
153
  w.google.accounts.oauth2 = {};
97
154
  }
98
155
  w.google.accounts.oauth2.initTokenClient = initTokenClient;
156
+ w.google.accounts.oauth2.initCodeClient = initCodeClient;
99
157
  },
100
158
  uninstall() {
101
159
  const w = globalThis;
@@ -110,7 +168,11 @@ export function createGisFake() {
110
168
  responseQueue.length = 0;
111
169
  popupErrorQueue.length = 0;
112
170
  popupClosedRaceQueue.length = 0;
171
+ silenceQueue = 0;
113
172
  calls.length = 0;
173
+ codeResponseQueue.length = 0;
174
+ codeErrorQueue.length = 0;
175
+ codeCalls.length = 0;
114
176
  },
115
177
  };
116
178
  }
@@ -1,6 +1,8 @@
1
1
  export { createGisFake } from './gisFake.js';
2
- export type { GisFake, GisTokenResponse, GisRecordedCall } from './gisFake.js';
2
+ export type { GisFake, GisTokenResponse, GisRecordedCall, GisCodeResponse, GisCodeRecordedCall, GisCodeClientConfig, GisCodeClient, } from './gisFake.js';
3
3
  export { createDriveFake } from './driveFake.js';
4
4
  export type { DriveFake, DriveFakeFile, DriveFakePermission, StatusOverrideOptions } from './driveFake.js';
5
5
  export { createPickerFake } from './pickerFake.js';
6
6
  export type { PickerFake, PickerFakeFile, PickerRecordedCall } from './pickerFake.js';
7
+ export { createTokenExchangeFake } from './tokenExchangeFake.js';
8
+ export type { TokenExchangeFake, TokenExchangeRecordedCall, CreateTokenExchangeFakeOptions, } from './tokenExchangeFake.js';
@@ -1,3 +1,4 @@
1
1
  export { createGisFake } from './gisFake.js';
2
2
  export { createDriveFake } from './driveFake.js';
3
3
  export { createPickerFake } from './pickerFake.js';
4
+ export { createTokenExchangeFake } from './tokenExchangeFake.js';
@@ -0,0 +1,67 @@
1
+ /**
2
+ * An in-memory fake of the server-mediated token-exchange endpoint that
3
+ * drive-sync talks to when `DriveSyncOptions.tokenExchangeUrl` is set.
4
+ *
5
+ * The real server accepts `POST {tokenExchangeUrl}` with a body that is
6
+ * EXACTLY one of `{ code }` (new grant) or `{ envelope }` (refresh) and
7
+ * responds `200 { envelope }`. On a refresh it echoes the envelope
8
+ * unchanged while it is still fresh (`Date.now() < expiry_date - 60000`),
9
+ * otherwise it mints a new `payload` + `sig` under the same `guid`.
10
+ *
11
+ * Intended use:
12
+ *
13
+ * ```ts
14
+ * const tokenExchange = createTokenExchangeFake({ now: () => clock })
15
+ * tokenExchange.install()
16
+ * // ... exercise code under test ...
17
+ * expect(tokenExchange.calls[0].kind).toBe('code')
18
+ * tokenExchange.uninstall()
19
+ * ```
20
+ *
21
+ * Only requests whose URL matches the configured endpoint are intercepted;
22
+ * everything else falls through to whatever `globalThis.fetch` was before
23
+ * `install()`.
24
+ */
25
+ import type { Envelope } from '../types.js';
26
+ /** One recorded request against the fake endpoint. */
27
+ export interface TokenExchangeRecordedCall {
28
+ /** `'code'` for a new-grant body, `'envelope'` for a refresh body. */
29
+ kind: 'code' | 'envelope';
30
+ /** The parsed JSON request body, exactly as received. */
31
+ body: unknown;
32
+ }
33
+ export interface TokenExchangeFake {
34
+ /** Swap `globalThis.fetch` for the intercepting handler. Idempotent. */
35
+ install(): void;
36
+ /** Restore the exact `globalThis.fetch` that was present at `install()`. Idempotent. */
37
+ uninstall(): void;
38
+ /** Every intercepted request, in call order. */
39
+ readonly calls: TokenExchangeRecordedCall[];
40
+ /** The most recently minted / echoed envelope, or `null` before the first call. */
41
+ readonly lastEnvelope: Envelope | null;
42
+ /** Make the next `times` (default 1) calls fail `410 refresh_token_revoked`. */
43
+ fail410(times?: number): void;
44
+ /** Make the next `times` (default 1) calls fail `502 google_unavailable`. */
45
+ fail502(times?: number): void;
46
+ /** Make the next `times` (default 1) calls fail `400 malformed_request`. */
47
+ failMalformed(times?: number): void;
48
+ /** Make the next `times` (default 1) calls fail `401 invalid_envelope_signature`. */
49
+ failInvalidSig(times?: number): void;
50
+ /** Make the next `times` (default 1) calls fail `404 unknown_guid`. */
51
+ failUnknownGuid(times?: number): void;
52
+ /**
53
+ * Re-stamp `lastEnvelope`'s expiry to `now() + msFromNow` (and re-sign it),
54
+ * so a subsequent replay of that envelope is treated as stale. Throws if no
55
+ * envelope has been minted yet.
56
+ */
57
+ setExpiry(msFromNow: number): Promise<void>;
58
+ }
59
+ export interface CreateTokenExchangeFakeOptions {
60
+ /** HMAC secret used to sign minted envelopes. Defaults to a fixed test value. */
61
+ secret?: string;
62
+ /** Injectable clock; defaults to `Date.now`. */
63
+ now?: () => number;
64
+ }
65
+ /** Which crypto backend the last signing operation used. Exposed for diagnostics. */
66
+ export declare let lastCryptoBackend: 'crypto.subtle' | 'node:crypto' | null;
67
+ export declare function createTokenExchangeFake(opts?: CreateTokenExchangeFakeOptions): TokenExchangeFake;
@@ -0,0 +1,253 @@
1
+ /**
2
+ * An in-memory fake of the server-mediated token-exchange endpoint that
3
+ * drive-sync talks to when `DriveSyncOptions.tokenExchangeUrl` is set.
4
+ *
5
+ * The real server accepts `POST {tokenExchangeUrl}` with a body that is
6
+ * EXACTLY one of `{ code }` (new grant) or `{ envelope }` (refresh) and
7
+ * responds `200 { envelope }`. On a refresh it echoes the envelope
8
+ * unchanged while it is still fresh (`Date.now() < expiry_date - 60000`),
9
+ * otherwise it mints a new `payload` + `sig` under the same `guid`.
10
+ *
11
+ * Intended use:
12
+ *
13
+ * ```ts
14
+ * const tokenExchange = createTokenExchangeFake({ now: () => clock })
15
+ * tokenExchange.install()
16
+ * // ... exercise code under test ...
17
+ * expect(tokenExchange.calls[0].kind).toBe('code')
18
+ * tokenExchange.uninstall()
19
+ * ```
20
+ *
21
+ * Only requests whose URL matches the configured endpoint are intercepted;
22
+ * everything else falls through to whatever `globalThis.fetch` was before
23
+ * `install()`.
24
+ */
25
+ /** Default endpoint drive-sync ships with. */
26
+ const DEFAULT_TOKEN_EXCHANGE_URL = 'https://open-webapp.duckdns.org/callback';
27
+ /** Scope minted into every fake payload. */
28
+ const FAKE_SCOPE = 'https://www.googleapis.com/auth/drive.file';
29
+ /** Lifetime, in ms, of a freshly minted payload. */
30
+ const TOKEN_LIFETIME_MS = 3_600_000;
31
+ /** Server-side freshness skew: an envelope inside this window is echoed as-is. */
32
+ const REFRESH_SKEW_MS = 60_000;
33
+ /** Canonical JSON: object keys sorted recursively, no insignificant whitespace. */
34
+ function canonicalJson(value) {
35
+ if (value === null || typeof value !== 'object')
36
+ return JSON.stringify(value);
37
+ if (Array.isArray(value))
38
+ return '[' + value.map(canonicalJson).join(',') + ']';
39
+ const entries = Object.keys(value)
40
+ .sort()
41
+ .map((key) => JSON.stringify(key) + ':' + canonicalJson(value[key]));
42
+ return '{' + entries.join(',') + '}';
43
+ }
44
+ function base64UrlFromBytes(bytes) {
45
+ let binary = '';
46
+ for (let i = 0; i < bytes.length; i += 1)
47
+ binary += String.fromCharCode(bytes[i]);
48
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
49
+ }
50
+ /** Which crypto backend the last signing operation used. Exposed for diagnostics. */
51
+ export let lastCryptoBackend = null;
52
+ async function hmacSha256Base64Url(secret, message) {
53
+ const encoder = new TextEncoder();
54
+ const subtle = globalThis.crypto?.subtle;
55
+ if (subtle) {
56
+ lastCryptoBackend = 'crypto.subtle';
57
+ const key = await subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
58
+ 'sign',
59
+ ]);
60
+ const mac = await subtle.sign('HMAC', key, encoder.encode(message));
61
+ return base64UrlFromBytes(new Uint8Array(mac));
62
+ }
63
+ // Fall back to Node's crypto when Web Crypto is unavailable in the test env.
64
+ // Computed specifier keeps TS from trying to resolve `node:crypto` types.
65
+ lastCryptoBackend = 'node:crypto';
66
+ const specifier = 'node:' + 'crypto';
67
+ const nodeCrypto = await import(/* @vite-ignore */ specifier);
68
+ return nodeCrypto.createHmac('sha256', secret).update(message).digest('base64url');
69
+ }
70
+ function randomUuid() {
71
+ const webCrypto = globalThis.crypto;
72
+ if (webCrypto?.randomUUID)
73
+ return webCrypto.randomUUID();
74
+ // Extremely defensive fallback; the test env always has crypto.randomUUID.
75
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
76
+ const r = (Math.random() * 16) | 0;
77
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
78
+ return v.toString(16);
79
+ });
80
+ }
81
+ function randomAccessToken() {
82
+ const webCrypto = globalThis.crypto;
83
+ const bytes = new Uint8Array(24);
84
+ if (webCrypto?.getRandomValues)
85
+ webCrypto.getRandomValues(bytes);
86
+ else
87
+ for (let i = 0; i < bytes.length; i += 1)
88
+ bytes[i] = (Math.random() * 256) | 0;
89
+ let hex = '';
90
+ for (let i = 0; i < bytes.length; i += 1)
91
+ hex += bytes[i].toString(16).padStart(2, '0');
92
+ return 'ya29.' + hex;
93
+ }
94
+ function jsonResponse(status, body) {
95
+ return new Response(JSON.stringify(body), {
96
+ status,
97
+ headers: { 'content-type': 'application/json' },
98
+ });
99
+ }
100
+ function errorResponse(status, code, message) {
101
+ return jsonResponse(status, { error: { code, message } });
102
+ }
103
+ const OVERRIDE_RESPONSES = {
104
+ '410': () => errorResponse(410, 'refresh_token_revoked', 'The refresh token has been revoked'),
105
+ '502': () => errorResponse(502, 'google_unavailable', 'Upstream Google token endpoint is unavailable'),
106
+ malformed: () => errorResponse(400, 'malformed_request', 'The request body was malformed'),
107
+ invalidSig: () => errorResponse(401, 'invalid_envelope_signature', 'The envelope signature did not verify'),
108
+ unknownGuid: () => errorResponse(404, 'unknown_guid', 'No grant exists for that guid'),
109
+ };
110
+ function urlOf(input) {
111
+ if (typeof input === 'string')
112
+ return input;
113
+ if (input instanceof URL)
114
+ return input.href;
115
+ if (typeof Request !== 'undefined' && input instanceof Request)
116
+ return input.url;
117
+ return String(input?.url ?? input);
118
+ }
119
+ async function bodyTextOf(input, init) {
120
+ if (init && typeof init.body === 'string')
121
+ return init.body;
122
+ if (init && init.body != null)
123
+ return String(init.body);
124
+ if (typeof Request !== 'undefined' && input instanceof Request)
125
+ return input.clone().text();
126
+ return '';
127
+ }
128
+ export function createTokenExchangeFake(opts = {}) {
129
+ const secret = opts.secret ?? 'token-exchange-fake-secret';
130
+ const now = opts.now ?? (() => Date.now());
131
+ const calls = [];
132
+ const overrideQueue = [];
133
+ let lastEnvelope = null;
134
+ let installed = false;
135
+ let previousFetch = globalThis.fetch;
136
+ function matchesEndpoint(rawUrl) {
137
+ if (rawUrl === DEFAULT_TOKEN_EXCHANGE_URL)
138
+ return true;
139
+ try {
140
+ return new URL(rawUrl).pathname === '/callback';
141
+ }
142
+ catch {
143
+ return rawUrl.endsWith('/callback');
144
+ }
145
+ }
146
+ async function mintEnvelope(guid) {
147
+ const payload = {
148
+ access_token: randomAccessToken(),
149
+ expiry_date: now() + TOKEN_LIFETIME_MS,
150
+ token_type: 'Bearer',
151
+ scope: FAKE_SCOPE,
152
+ };
153
+ const sig = await hmacSha256Base64Url(secret, canonicalJson({ v: 2, guid, payload }));
154
+ return { v: 2, guid, payload, sig };
155
+ }
156
+ function enqueue(kind, times) {
157
+ const n = Math.max(0, Math.floor(times));
158
+ for (let i = 0; i < n; i += 1)
159
+ overrideQueue.push(kind);
160
+ }
161
+ const handler = (async (input, init) => {
162
+ const rawUrl = urlOf(input);
163
+ if (!matchesEndpoint(rawUrl))
164
+ return previousFetch(input, init);
165
+ let parsed;
166
+ try {
167
+ const text = await bodyTextOf(input, init);
168
+ parsed = text ? JSON.parse(text) : {};
169
+ }
170
+ catch {
171
+ return errorResponse(400, 'malformed_request', 'The request body was not valid JSON');
172
+ }
173
+ const record = parsed !== null && typeof parsed === 'object' ? parsed : {};
174
+ const hasCode = record.code != null;
175
+ const hasEnvelope = record.envelope != null;
176
+ const kind = hasEnvelope && !hasCode ? 'envelope' : 'code';
177
+ calls.push({ kind, body: parsed });
178
+ const override = overrideQueue.shift();
179
+ if (override)
180
+ return OVERRIDE_RESPONSES[override]();
181
+ if (hasCode && hasEnvelope) {
182
+ return errorResponse(400, 'code_and_envelope_exclusive', 'Provide exactly one of `code` or `envelope`');
183
+ }
184
+ if (!hasCode && !hasEnvelope) {
185
+ return errorResponse(400, 'code_or_envelope_required', 'Provide exactly one of `code` or `envelope`');
186
+ }
187
+ if (hasCode) {
188
+ const minted = await mintEnvelope(randomUuid());
189
+ lastEnvelope = minted;
190
+ return jsonResponse(200, { envelope: minted });
191
+ }
192
+ const incoming = record.envelope;
193
+ if (incoming === null ||
194
+ typeof incoming !== 'object' ||
195
+ typeof incoming.payload !== 'object' ||
196
+ incoming.payload === null ||
197
+ typeof incoming.payload.expiry_date !== 'number' ||
198
+ typeof incoming.guid !== 'string') {
199
+ return errorResponse(400, 'malformed_request', 'The `envelope` was not a well-formed envelope');
200
+ }
201
+ if (now() < incoming.payload.expiry_date - REFRESH_SKEW_MS) {
202
+ lastEnvelope = incoming;
203
+ return jsonResponse(200, { envelope: incoming });
204
+ }
205
+ const refreshed = await mintEnvelope(incoming.guid);
206
+ lastEnvelope = refreshed;
207
+ return jsonResponse(200, { envelope: refreshed });
208
+ });
209
+ return {
210
+ install() {
211
+ if (installed)
212
+ return;
213
+ previousFetch = globalThis.fetch;
214
+ globalThis.fetch = handler;
215
+ installed = true;
216
+ },
217
+ uninstall() {
218
+ if (!installed)
219
+ return;
220
+ globalThis.fetch = previousFetch;
221
+ installed = false;
222
+ },
223
+ get calls() {
224
+ return calls;
225
+ },
226
+ get lastEnvelope() {
227
+ return lastEnvelope;
228
+ },
229
+ fail410(times = 1) {
230
+ enqueue('410', times);
231
+ },
232
+ fail502(times = 1) {
233
+ enqueue('502', times);
234
+ },
235
+ failMalformed(times = 1) {
236
+ enqueue('malformed', times);
237
+ },
238
+ failInvalidSig(times = 1) {
239
+ enqueue('invalidSig', times);
240
+ },
241
+ failUnknownGuid(times = 1) {
242
+ enqueue('unknownGuid', times);
243
+ },
244
+ async setExpiry(msFromNow) {
245
+ if (!lastEnvelope) {
246
+ throw new Error('createTokenExchangeFake: setExpiry() called before any envelope was minted');
247
+ }
248
+ const payload = { ...lastEnvelope.payload, expiry_date: now() + msFromNow };
249
+ const sig = await hmacSha256Base64Url(secret, canonicalJson({ v: 2, guid: lastEnvelope.guid, payload }));
250
+ lastEnvelope = { v: 2, guid: lastEnvelope.guid, payload, sig };
251
+ },
252
+ };
253
+ }
package/dist/token.d.ts CHANGED
@@ -31,6 +31,20 @@ export interface AcquireTokenOptions {
31
31
  * turns out to already be usable.
32
32
  */
33
33
  export declare function notifyExternalTokenRefresh(projectId: string): void;
34
+ /**
35
+ * Records that another tab just persisted a fresh envelope for `projectId`.
36
+ * The next {@link import('./envelope.js').refreshEnvelope} call for this
37
+ * project drains the signal and re-reads the stored envelope before deciding
38
+ * whether a network round-trip is needed.
39
+ */
40
+ export declare function notifyExternalEnvelopeRefresh(projectId: string): void;
41
+ /**
42
+ * Consume (one-shot) a pending cross-tab envelope-refresh signal for
43
+ * `projectId`. Returns `true` when a signal was pending (and clears it),
44
+ * `false` otherwise. Mirrors the inline `externallyRefreshed.delete(...)`
45
+ * check the legacy `acquireToken` path performs.
46
+ */
47
+ export declare function consumeExternalEnvelopeRefresh(projectId: string): boolean;
34
48
  /**
35
49
  * Single entry point for acquiring a Drive access token, used by BOTH the
36
50
  * interactive "connect" path (interactive: true -> prompt: '', i.e. no
package/dist/token.js CHANGED
@@ -24,6 +24,26 @@ const POPUP_CLOSED_GRACE_MS = 2000;
24
24
  */
25
25
  const PROBE_ATTEMPTS = 3;
26
26
  const PROBE_RETRY_DELAY_MS = 350;
27
+ /**
28
+ * Hard ceiling on a single GIS token request.
29
+ *
30
+ * GIS settles a request ONLY by invoking `callback` or `error_callback`, and
31
+ * in the field it sometimes does neither: a completed flow whose result is
32
+ * never posted back to this page (most reliably a silent `prompt: 'none'`
33
+ * request in a browser that blocks silent token issuance) leaves both
34
+ * callbacks unfired. Without a ceiling that request stays pending forever —
35
+ * `connect()` never settles, the host app is stuck mid-connect with no error
36
+ * to show, and the in-flight entry in `inFlight` is never released, so every
37
+ * later retry joins the same dead promise and no popup ever opens again.
38
+ *
39
+ * Interactive requests get a generous ceiling because the user is legitimately
40
+ * typing a password inside the popup; silent requests have no UI and must
41
+ * either answer quickly or be treated as failed.
42
+ */
43
+ const INTERACTIVE_REQUEST_TIMEOUT_MS = 5 * 60_000;
44
+ const SILENT_REQUEST_TIMEOUT_MS = 10_000;
45
+ /** Probes run up to PROBE_ATTEMPTS times, so each one has to fail fast. */
46
+ const PROBE_REQUEST_TIMEOUT_MS = 4_000;
27
47
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
28
48
  /**
29
49
  * Persists a freshly-acquired GIS token response as a StoredToken, deriving
@@ -41,12 +61,19 @@ export async function persistTokenResponse(appId, projectId, response) {
41
61
  return token;
42
62
  }
43
63
  /**
44
- * Key used for in-flight coalescing: per (projectId, sorted-scope-set), NOT
45
- * global. This is what keeps concurrent calls for different projects (or
46
- * different scope requirements within the same project) from colliding.
64
+ * Key used for in-flight coalescing: per (projectId, sorted-scope-set,
65
+ * interactive), NOT global. This is what keeps concurrent calls for different
66
+ * projects (or different scope requirements within the same project) from
67
+ * colliding.
68
+ *
69
+ * `interactive` is part of the key because the two modes are not
70
+ * interchangeable: a user-initiated `connect()` must run its own OAuth flow,
71
+ * and must never be handed the outcome of a silent background refresh that
72
+ * happens to be in flight — that resolves (or rejects) the user's click with
73
+ * no flow shown at all, which is indistinguishable from a dead button.
47
74
  */
48
- function coalesceKey(projectId, scopes) {
49
- return `${projectId}|${scopes.slice().sort().join(' ')}`;
75
+ function coalesceKey(projectId, scopes, interactive) {
76
+ return `${projectId}|${interactive ? 'i' : 's'}|${scopes.slice().sort().join(' ')}`;
50
77
  }
51
78
  const inFlight = new Map();
52
79
  /**
@@ -69,6 +96,34 @@ const externallyRefreshed = new Set();
69
96
  export function notifyExternalTokenRefresh(projectId) {
70
97
  externallyRefreshed.add(projectId);
71
98
  }
99
+ /**
100
+ * Parallel-set mirror of {@link externallyRefreshed} for the server-mediated
101
+ * token-exchange path (envelope.ts `refreshEnvelope`). Kept deliberately
102
+ * separate from the legacy set so the two flows never consume each other's
103
+ * cross-tab signals: a `token` broadcast that originated from an envelope
104
+ * refresh drains this set, and one from a legacy GIS acquisition drains the
105
+ * other. Populated by {@link notifyExternalEnvelopeRefresh}, drained by
106
+ * {@link consumeExternalEnvelopeRefresh}.
107
+ */
108
+ const externallyRefreshedEnvelope = new Set();
109
+ /**
110
+ * Records that another tab just persisted a fresh envelope for `projectId`.
111
+ * The next {@link import('./envelope.js').refreshEnvelope} call for this
112
+ * project drains the signal and re-reads the stored envelope before deciding
113
+ * whether a network round-trip is needed.
114
+ */
115
+ export function notifyExternalEnvelopeRefresh(projectId) {
116
+ externallyRefreshedEnvelope.add(projectId);
117
+ }
118
+ /**
119
+ * Consume (one-shot) a pending cross-tab envelope-refresh signal for
120
+ * `projectId`. Returns `true` when a signal was pending (and clears it),
121
+ * `false` otherwise. Mirrors the inline `externallyRefreshed.delete(...)`
122
+ * check the legacy `acquireToken` path performs.
123
+ */
124
+ export function consumeExternalEnvelopeRefresh(projectId) {
125
+ return externallyRefreshedEnvelope.delete(projectId);
126
+ }
72
127
  /**
73
128
  * Single entry point for acquiring a Drive access token, used by BOTH the
74
129
  * interactive "connect" path (interactive: true -> prompt: '', i.e. no
@@ -101,7 +156,7 @@ export async function acquireToken(opts) {
101
156
  return stored;
102
157
  }
103
158
  }
104
- const key = coalesceKey(opts.projectId, opts.scopes);
159
+ const key = coalesceKey(opts.projectId, opts.scopes, opts.interactive);
105
160
  const existing = inFlight.get(key);
106
161
  if (existing) {
107
162
  return existing;
@@ -140,7 +195,7 @@ async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
140
195
  try {
141
196
  // No grace window: `prompt: 'none'` never opens a popup, so there is no
142
197
  // popup-closed poll to race and nothing to wait out on failure.
143
- const response = await requestGisToken(initTokenClient, opts, { prompt: 'none', hint: opts.hint }, 0);
198
+ const response = await requestGisToken(initTokenClient, opts, { prompt: 'none', hint: opts.hint }, 0, PROBE_REQUEST_TIMEOUT_MS);
144
199
  opts.logger?.debug('drive-sync: recovered a completed sign-in reported as popup_closed', {
145
200
  projectId: opts.projectId,
146
201
  attempt,
@@ -167,9 +222,33 @@ async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
167
222
  * captured in THIS call's closure only — never on a module-level variable — so
168
223
  * a second concurrent call cannot clobber the first caller's promise.
169
224
  */
170
- function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = POPUP_CLOSED_GRACE_MS) {
225
+ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = POPUP_CLOSED_GRACE_MS, timeoutMs = INTERACTIVE_REQUEST_TIMEOUT_MS) {
171
226
  return new Promise((resolve, reject) => {
172
227
  let settled = false;
228
+ // Neither GIS callback is guaranteed to fire; see the timeout constants.
229
+ const timeout = setTimeout(() => {
230
+ if (settled)
231
+ return;
232
+ settled = true;
233
+ opts.logger?.warn('drive-sync: GIS never returned a result; timing out the request', {
234
+ projectId: opts.projectId,
235
+ prompt: override.prompt,
236
+ timeoutMs,
237
+ });
238
+ reject(new NeedsReauthError('Google sign-in did not return a result', {
239
+ reason: 'gis_timeout',
240
+ }));
241
+ }, timeoutMs);
242
+ const succeed = (res) => {
243
+ settled = true;
244
+ clearTimeout(timeout);
245
+ resolve(res);
246
+ };
247
+ const fail = (err) => {
248
+ settled = true;
249
+ clearTimeout(timeout);
250
+ reject(err);
251
+ };
173
252
  const client = initTokenClient({
174
253
  client_id: opts.clientId,
175
254
  scope: opts.scopes.join(' '),
@@ -185,12 +264,11 @@ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = P
185
264
  });
186
265
  return;
187
266
  }
188
- settled = true;
189
267
  if (res.error) {
190
- reject(new Error(`GIS token request failed: ${res.error}`));
268
+ fail(new Error(`GIS token request failed: ${res.error}`));
191
269
  return;
192
270
  }
193
- resolve(res);
271
+ succeed(res);
194
272
  },
195
273
  // Without this, a popup that the browser blocks or the user closes
196
274
  // settles NOTHING: GIS reports those through error_callback only, so
@@ -216,15 +294,13 @@ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = P
216
294
  setTimeout(() => {
217
295
  if (settled)
218
296
  return;
219
- settled = true;
220
- reject(new NeedsReauthError('Google sign-in popup was closed before completing', {
297
+ fail(new NeedsReauthError('Google sign-in popup was closed before completing', {
221
298
  reason: 'popup_closed',
222
299
  }));
223
300
  }, popupClosedGraceMs);
224
301
  return;
225
302
  }
226
- settled = true;
227
- reject(new NeedsReauthError(err?.type === 'popup_failed_to_open'
303
+ fail(new NeedsReauthError(err?.type === 'popup_failed_to_open'
228
304
  ? 'Google sign-in popup was blocked by the browser'
229
305
  : `Google sign-in failed: ${err?.type ?? 'unknown error'}`, { reason: err?.type ?? 'gis_error' }));
230
306
  },
@@ -262,7 +338,7 @@ async function acquireTokenUncoalesced(opts) {
262
338
  // the chooser too.
263
339
  prompt: opts.interactive ? '' : 'none',
264
340
  hint: opts.hint,
265
- });
341
+ }, POPUP_CLOSED_GRACE_MS, opts.interactive ? INTERACTIVE_REQUEST_TIMEOUT_MS : SILENT_REQUEST_TIMEOUT_MS);
266
342
  }
267
343
  catch (err) {
268
344
  if (!opts.interactive) {