@open-webapp/drive-sync 0.5.2 → 0.5.4

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/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/errors.d.ts CHANGED
@@ -46,6 +46,15 @@ export declare class NotFoundError extends DriveSyncError {
46
46
  fileId: string;
47
47
  constructor(fileId: string, opts?: DriveSyncErrorOptions);
48
48
  }
49
+ /**
50
+ * Thrown when Drive refuses to serve a file's raw bytes via `alt=media`
51
+ * because it isn't binary content (e.g. a native Google Docs/Sheets/Slides
52
+ * file, which only supports Export, not Download).
53
+ */
54
+ export declare class NotDownloadableError extends DriveSyncError {
55
+ fileId: string;
56
+ constructor(fileId: string, opts?: DriveSyncErrorOptions);
57
+ }
49
58
  export interface RateLimitedErrorOptions extends DriveSyncErrorOptions {
50
59
  retryAfter?: number;
51
60
  }
package/dist/errors.js CHANGED
@@ -59,6 +59,19 @@ export class NotFoundError extends DriveSyncError {
59
59
  this.fileId = fileId;
60
60
  }
61
61
  }
62
+ /**
63
+ * Thrown when Drive refuses to serve a file's raw bytes via `alt=media`
64
+ * because it isn't binary content (e.g. a native Google Docs/Sheets/Slides
65
+ * file, which only supports Export, not Download).
66
+ */
67
+ export class NotDownloadableError extends DriveSyncError {
68
+ fileId;
69
+ constructor(fileId, opts) {
70
+ super(`File not downloadable as raw content: ${fileId}`, { status: 403, ...opts });
71
+ this.name = 'NotDownloadableError';
72
+ this.fileId = fileId;
73
+ }
74
+ }
62
75
  /** Thrown on a 429 response. */
63
76
  export class RateLimitedError extends DriveSyncError {
64
77
  retryAfter;
package/dist/files.d.ts CHANGED
@@ -18,7 +18,9 @@ export interface ReadOptions extends BaseCallOptions {
18
18
  * since Drive 404s both for a genuinely wrong id and for a file the
19
19
  * connected account cannot see — this ambiguity is documented in the public
20
20
  * API and callers are expected to treat `null` as "not available" rather
21
- * than distinguishing the two cases.
21
+ * than distinguishing the two cases. Also returns `null` for a native Google
22
+ * Workspace file (Docs/Sheets/Slides/...), since those only support Export,
23
+ * not the raw `alt=media` download this function issues.
22
24
  */
23
25
  export declare function read(opts: ReadOptions): Promise<string | Blob | null>;
24
26
  export interface RemoveOptions extends BaseCallOptions {
package/dist/files.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { driveFetch } from './http.js';
2
2
  import { escapeQ } from './query.js';
3
- import { NotFoundError, RemoteChangedError } from './errors.js';
3
+ import { NotFoundError, NotDownloadableError, RemoteChangedError } from './errors.js';
4
4
  import { getFileState, setFileState, clearFileState } from './storage.js';
5
5
  const DRIVE_BASE = 'https://www.googleapis.com/drive/v3';
6
6
  const UPLOAD_BASE = 'https://www.googleapis.com/upload/drive/v3';
@@ -53,7 +53,9 @@ async function recordBaseline(opts, fileId, version) {
53
53
  * since Drive 404s both for a genuinely wrong id and for a file the
54
54
  * connected account cannot see — this ambiguity is documented in the public
55
55
  * API and callers are expected to treat `null` as "not available" rather
56
- * than distinguishing the two cases.
56
+ * than distinguishing the two cases. Also returns `null` for a native Google
57
+ * Workspace file (Docs/Sheets/Slides/...), since those only support Export,
58
+ * not the raw `alt=media` download this function issues.
57
59
  */
58
60
  export async function read(opts) {
59
61
  try {
@@ -88,7 +90,7 @@ export async function read(opts) {
88
90
  return content;
89
91
  }
90
92
  catch (err) {
91
- if (err instanceof NotFoundError) {
93
+ if (err instanceof NotFoundError || err instanceof NotDownloadableError) {
92
94
  return null;
93
95
  }
94
96
  throw err;
package/dist/http.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { acquireToken } from './token.js';
2
2
  import { getConnection, refreshSilently } from './connection.js';
3
3
  import { clearToken, getToken } from './storage.js';
4
- import { DriveSyncError, NeedsReauthError, ScopeInsufficientError, NotFoundError, RateLimitedError, TransientError, WrongAccountError, } from './errors.js';
4
+ import { DriveSyncError, NeedsReauthError, ScopeInsufficientError, NotFoundError, NotDownloadableError, RateLimitedError, TransientError, WrongAccountError, } from './errors.js';
5
5
  const MAX_ATTEMPTS = 3;
6
6
  const BASE_DELAY_MS = 500;
7
7
  /** Mirrors connection.ts's own buffer: a cached token this close to expiry is treated as unusable. */
@@ -182,6 +182,12 @@ async function performFetch(opts, accessToken, isRetryAfter401) {
182
182
  reason: lastBodyText,
183
183
  });
184
184
  }
185
+ if (lastBodyText.includes('fileNotDownloadable')) {
186
+ // No fileId is reliably available at this layer (see the 404 case
187
+ // below) — files.ts re-catches this and returns null instead of
188
+ // rethrowing with the concrete fileId, so the empty default is fine.
189
+ throw new NotDownloadableError('', { status: 403, reason: lastBodyText });
190
+ }
185
191
  throw new DriveSyncError(`Drive request forbidden (403): ${lastBodyText}`, {
186
192
  status: 403,
187
193
  reason: lastBodyText,
package/dist/index.js CHANGED
@@ -227,7 +227,11 @@ export function createDriveSync(options) {
227
227
  });
228
228
  const results = [];
229
229
  for (const p of picked) {
230
- const content = await filesImpl.read({ ...base, fileId: p.fileId, interactive: true });
230
+ // Not interactive: the token above was already acquired
231
+ // interactively moments ago for the Picker itself and is now
232
+ // cached, so each file read reuses it (or silently refreshes)
233
+ // instead of forcing its own consent prompt per file.
234
+ const content = await filesImpl.read({ ...base, fileId: p.fileId, interactive: false });
231
235
  results.push({ fileId: p.fileId, name: p.name, mimeType: p.mimeType, content });
232
236
  }
233
237
  return results;
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.4",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",