@open-webapp/drive-sync 0.5.2 → 0.5.3

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.
Files changed (3) hide show
  1. package/SPEC.md +5 -0
  2. package/dist/token.js +91 -18
  3. package/package.json +1 -1
package/SPEC.md CHANGED
@@ -145,6 +145,11 @@ Token acquisition always funnels through `token.ts`'s `acquireToken`, which is c
145
145
  Concretely, by module:
146
146
 
147
147
  - **`token.ts`** is the only place that talks to GIS's `initTokenClient`. It does not know about "wrong account" — it just returns whatever token GIS hands back for the requested `(scopes, prompt, hint)`.
148
+ - **`token.ts`'s `popup_closed` handling** is deliberately two-stage, because GIS reports `popup_closed` for *completed* sign-ins as well as cancelled ones:
149
+ 1. **Grace window** (`POPUP_CLOSED_GRACE_MS`, 2000ms) — wait for a success `callback` that is merely late to still win.
150
+ 2. **Completed-grant probe** (interactive path only) — if no token arrived, issue one `prompt:'none'` request before giving up. A completed consent leaves a live grant at Google, so this resolves with no popup; a cancelled sign-in leaves none, and the original `NeedsReauthError{reason:'popup_closed'}` is rethrown unchanged (never the probe's own error).
151
+
152
+ Stage 2 exists because the success `callback` sometimes never arrives *at all* for a completed sign-in, which no grace window can fix. The silent path skips the probe — it is already a `prompt:'none'` request.
148
153
  - **`connection.ts`**'s `refreshSilently` is the *only* place that adds wrong-account verification: after `acquireToken({interactive:false, hint:expectedEmail})` resolves, it calls the injected `fetchEmail(token.accessToken)` and compares the result against `expectedEmail`. Mismatch → `clearToken()` then throw `WrongAccountError`; match → return the token.
149
154
  - **`http.ts`**'s `driveFetch`/`performFetch` is the 401 path: on a first 401 (not already a retry, not an interactive call), it clears the token and, if a `fetchEmail` was supplied and a connection's email is known, calls `refreshSilently`; otherwise falls back to a bare `acquireToken`. It retries the original request exactly once (`isRetryAfter401` flag) with whatever token comes back. A second 401, or any 401 on an interactive call, throws `NeedsReauthError` without retrying again. A `WrongAccountError` from `refreshSilently` is re-thrown as-is rather than being swallowed into `NeedsReauthError`.
150
155
  - **`refresh.ts`**'s `warmUpIfNeeded` is the proactive path: fired from `visibilitychange`→`visible` and `pageshow`(persisted, not hidden) listeners attached by `activate()`. It only acts if a `conn` record exists **and** the cached token is missing or within `REFRESH_BUFFER_MS` (5 minutes) of `expiresAt`. When a `fetchEmail` is configured it goes through `refreshSilently` (so wrong-account detection also covers this path); otherwise it falls back to a bare `acquireToken`. It never *starts* a new attempt while the document is hidden — visibility is checked before it is ever called, so an attempt already in flight from before the tab hid is left to finish on its own.
package/dist/token.js CHANGED
@@ -8,6 +8,10 @@ import { createBroadcast } from './broadcast.js';
8
8
  * 300ms proved too tight in production: on a real network round-trip the
9
9
  * success token can arrive well after GIS's popup-closed poll fires,
10
10
  * causing genuinely successful sign-ins to be reported as NeedsReauthError.
11
+ *
12
+ * The grace window alone is NOT sufficient: in the field there are completed
13
+ * sign-ins where the success `callback` never arrives at all, so no window is
14
+ * long enough. `probeForCompletedGrant` below is what actually recovers those.
11
15
  */
12
16
  const POPUP_CLOSED_GRACE_MS = 2000;
13
17
  /**
@@ -99,26 +103,62 @@ export async function acquireToken(opts) {
99
103
  inFlight.delete(key);
100
104
  }
101
105
  }
102
- async function acquireTokenUncoalesced(opts) {
103
- await waitForGoogleIdentityServices(opts.logger);
104
- const w = globalThis;
105
- const initTokenClient = w.google?.accounts?.oauth2?.initTokenClient;
106
- if (!initTokenClient) {
107
- // waitForGoogleIdentityServices resolved, so this should not happen in
108
- // practice; guard anyway rather than throwing an obscure TypeError.
109
- throw new NeedsReauthError('Google Identity Services is unavailable');
106
+ function isPopupClosedError(err) {
107
+ return err instanceof NeedsReauthError && err.reason === 'popup_closed';
108
+ }
109
+ /**
110
+ * Issues ONE `prompt: 'none'` request to find out whether the sign-in that
111
+ * GIS reported as `popup_closed` actually completed. Resolves with the token
112
+ * response when a live grant is found; otherwise rethrows `popupClosedError`
113
+ * — the original interactive failure — so callers see the cancellation they
114
+ * would have seen before, never a confusing silent-path error.
115
+ */
116
+ async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
117
+ opts.logger?.debug('drive-sync: popup_closed with no token; probing for a completed grant', {
118
+ projectId: opts.projectId,
119
+ });
120
+ try {
121
+ // No grace window: `prompt: 'none'` never opens a popup, so there is no
122
+ // popup-closed poll to race and nothing to wait out on failure.
123
+ const response = await requestGisToken(initTokenClient, opts, { prompt: 'none', hint: opts.hint }, 0);
124
+ opts.logger?.debug('drive-sync: recovered a completed sign-in reported as popup_closed', {
125
+ projectId: opts.projectId,
126
+ });
127
+ return response;
110
128
  }
111
- const response = await new Promise((resolve, reject) => {
112
- // These resolve/reject are captured in THIS call's closure only, never
113
- // stored on a module-level variable, so a second concurrent call cannot
114
- // clobber the first caller's promise.
129
+ catch (probeError) {
130
+ opts.logger?.debug('drive-sync: no live grant after popup_closed; treating as cancelled', {
131
+ projectId: opts.projectId,
132
+ probeError,
133
+ });
134
+ throw popupClosedError;
135
+ }
136
+ }
137
+ /**
138
+ * Wraps a single GIS token request in a promise.
139
+ *
140
+ * Every call creates a FRESH `initTokenClient`, and the resolve/reject pair is
141
+ * captured in THIS call's closure only — never on a module-level variable — so
142
+ * a second concurrent call cannot clobber the first caller's promise.
143
+ */
144
+ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = POPUP_CLOSED_GRACE_MS) {
145
+ return new Promise((resolve, reject) => {
115
146
  let settled = false;
116
147
  const client = initTokenClient({
117
148
  client_id: opts.clientId,
118
149
  scope: opts.scopes.join(' '),
119
150
  callback: (res) => {
120
- if (settled)
151
+ if (settled) {
152
+ // Diagnostic only: a token arriving after we gave up is the exact
153
+ // signature of a grace window that was too short, and is worth
154
+ // distinguishing from one that never arrived at all.
155
+ opts.logger?.debug('drive-sync: GIS token callback arrived after settle', {
156
+ projectId: opts.projectId,
157
+ prompt: override.prompt,
158
+ hadError: Boolean(res.error),
159
+ });
121
160
  return;
161
+ }
122
162
  settled = true;
123
163
  if (res.error) {
124
164
  reject(new Error(`GIS token request failed: ${res.error}`));
@@ -131,6 +171,13 @@ async function acquireTokenUncoalesced(opts) {
131
171
  // the promise below would stay pending forever and every awaiting
132
172
  // Drive call would hang until the caller's own timeout (if any).
133
173
  error_callback: (err) => {
174
+ opts.logger?.debug('drive-sync: GIS error_callback', {
175
+ projectId: opts.projectId,
176
+ prompt: override.prompt,
177
+ type: err?.type,
178
+ message: err?.message,
179
+ settled,
180
+ });
134
181
  if (settled)
135
182
  return;
136
183
  if (err?.type === 'popup_closed') {
@@ -147,7 +194,7 @@ async function acquireTokenUncoalesced(opts) {
147
194
  reject(new NeedsReauthError('Google sign-in popup was closed before completing', {
148
195
  reason: 'popup_closed',
149
196
  }));
150
- }, POPUP_CLOSED_GRACE_MS);
197
+ }, popupClosedGraceMs);
151
198
  return;
152
199
  }
153
200
  settled = true;
@@ -159,17 +206,43 @@ async function acquireTokenUncoalesced(opts) {
159
206
  opts.logger?.debug('drive-sync: requesting access token', {
160
207
  projectId: opts.projectId,
161
208
  interactive: opts.interactive,
209
+ prompt: override.prompt,
162
210
  });
163
- client.requestAccessToken({
211
+ client.requestAccessToken(override);
212
+ });
213
+ }
214
+ async function acquireTokenUncoalesced(opts) {
215
+ await waitForGoogleIdentityServices(opts.logger);
216
+ const w = globalThis;
217
+ const initTokenClient = w.google?.accounts?.oauth2?.initTokenClient;
218
+ if (!initTokenClient) {
219
+ // waitForGoogleIdentityServices resolved, so this should not happen in
220
+ // practice; guard anyway rather than throwing an obscure TypeError.
221
+ throw new NeedsReauthError('Google Identity Services is unavailable');
222
+ }
223
+ let response;
224
+ try {
225
+ response = await requestGisToken(initTokenClient, opts, {
164
226
  prompt: opts.interactive ? 'consent' : 'none',
165
227
  hint: !opts.interactive ? opts.hint : undefined,
166
228
  });
167
- }).catch((err) => {
229
+ }
230
+ catch (err) {
168
231
  if (!opts.interactive) {
169
232
  throw new NeedsReauthError('Silent token acquisition failed', { reason: 'gis_error' });
170
233
  }
171
- throw err;
172
- });
234
+ if (!isPopupClosedError(err)) {
235
+ throw err;
236
+ }
237
+ // GIS said the popup closed and never delivered a token, but that is NOT
238
+ // proof the user cancelled: a completed consent whose success message is
239
+ // never posted back to this page looks identical from here. The two cases
240
+ // ARE distinguishable at Google, though — a completed consent leaves a
241
+ // live grant behind, so a `prompt: 'none'` request now succeeds with no
242
+ // popup at all. Probe for it; a cancelled sign-in leaves no grant and the
243
+ // probe fails, in which case we surface the original popup_closed error.
244
+ response = await probeForCompletedGrant(initTokenClient, opts, err);
245
+ }
173
246
  const token = await persistTokenResponse(opts.appId, opts.projectId, response);
174
247
  // Single choke point for the cross-tab "fresh token available" signal:
175
248
  // every acquisition path (interactive connect(), silent refreshSilently(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/drive-sync",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",