@open-webapp/drive-sync 0.5.6 → 0.5.7

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
@@ -56,10 +56,11 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
56
56
 
57
57
  1. **Per-request token client, not a module singleton** — `token.ts`'s `acquireToken`/`acquireTokenUncoalesced` creates a fresh `initTokenClient` on every call; nothing closes over the first call's `projectId`.
58
58
  2. **Scope honored on every call** — the fresh client is configured with `opts.scopes.join(' ')` per call, not baked in once at init.
59
- 3. **In-flight coalescing keyed by `(projectId, sorted scopes)`** — `token.ts`'s `coalesceKey` + `inFlight` map; concurrent calls for different projects/scopes never collide.
59
+ 3. **In-flight coalescing keyed by `(projectId, sorted scopes, interactive)`** — `token.ts`'s `coalesceKey` + `inFlight` map; concurrent calls for different projects/scopes never collide, and a user-initiated `connect()` is never handed the outcome of an in-flight silent refresh (which would settle the click with no OAuth flow shown).
60
60
  4. **No clobbered resolvers** — `resolve`/`reject` are captured in each call's own `Promise` closure (`acquireTokenUncoalesced`), never stored on a module-level variable.
61
61
  5. **Real expiry** — `persistTokenResponse` reads `response.expires_in` and computes `Date.now() + expiresIn * 1000`; no hardcoded `3600`.
62
- 6. **`grantedScopes` recorded** — `persistTokenResponse` splits `response.scope` and stores it on the token; `connection.ts`'s `connect()` also copies it onto the durable `ConnRecord`.
62
+ 6. **Every GIS request is time-bounded** — GIS settles a request only via `callback`/`error_callback`, and sometimes fires neither; `requestGisToken` rejects with `NeedsReauthError` (`reason: 'gis_timeout'`) after 5min interactive / 10s silent / 4s per recovery probe, so a request that is never answered cannot pin `inFlight` forever and kill every later retry.
63
+ 7. **`grantedScopes` recorded** — `persistTokenResponse` splits `response.scope` and stores it on the token; `connection.ts`'s `connect()` also copies it onto the durable `ConnRecord`.
63
64
  7. **401 handled** — `http.ts`'s `performFetch` clears the token, retries once non-interactively, then throws `NeedsReauthError` (see §4).
64
65
  8. **`hint` on silent refresh** — every non-interactive `acquireToken` call is given `hint: <known email>`; wrong-account tokens are caught by `refreshSilently` (see below and §4).
65
66
  9. **`response.ok` checked before parsing** — `performFetch` never calls `.json()`/`.text()` on a response without checking `res.ok` first; every status branch is explicit.
@@ -120,7 +121,7 @@ Nothing in this schema stores a Drive `folderId` or `fileId` — those stay app-
120
121
 
121
122
  ## 4. Refresh state machine
122
123
 
123
- Token acquisition always funnels through `token.ts`'s `acquireToken`, which is coalesced per `(projectId, sorted scopes)` and never keeps module-level mutable state across calls. Four distinct callers drive it, each representing a different "state":
124
+ Token acquisition always funnels through `token.ts`'s `acquireToken`, which is coalesced per `(projectId, sorted scopes, interactive)` and never keeps module-level mutable state across calls. Four distinct callers drive it, each representing a different "state":
124
125
 
125
126
  ```
126
127
  [No connection]
@@ -62,6 +62,13 @@ export interface GisFake {
62
62
  * the popup-closed poll fires before the success message is delivered.
63
63
  */
64
64
  queuePopupClosedRace(response: GisTokenResponse, delayMs: number): void;
65
+ /**
66
+ * Queue a request that GIS never answers at all: neither `callback` nor
67
+ * `error_callback` is ever invoked. This is the real-world shape of a flow
68
+ * whose result is never posted back to the page (e.g. a silent
69
+ * `prompt: 'none'` request in a browser that blocks silent token issuance).
70
+ */
71
+ queueSilence(): void;
65
72
  /** Stub `window.google.accounts.oauth2.initTokenClient` with this fake. */
66
73
  install(): void;
67
74
  /** Remove the stub installed by `install()`, restoring prior state. */
@@ -17,6 +17,7 @@ export function createGisFake() {
17
17
  const responseQueue = [];
18
18
  const popupErrorQueue = [];
19
19
  const popupClosedRaceQueue = [];
20
+ let silenceQueue = 0;
20
21
  const calls = [];
21
22
  let previousGoogle;
22
23
  let hadGoogle = false;
@@ -34,6 +35,10 @@ export function createGisFake() {
34
35
  const hint = overrideConfig?.hint ?? config.hint;
35
36
  const scope = overrideConfig?.scope ?? config.scope ?? '';
36
37
  calls.push({ prompt, hint, scope });
38
+ if (silenceQueue > 0) {
39
+ silenceQueue -= 1;
40
+ return;
41
+ }
37
42
  const popupClosedRace = popupClosedRaceQueue.shift();
38
43
  if (popupClosedRace) {
39
44
  const errorCallback = config.error_callback;
@@ -82,6 +87,9 @@ export function createGisFake() {
82
87
  queuePopupClosedRace(response, delayMs) {
83
88
  popupClosedRaceQueue.push({ response, delayMs });
84
89
  },
90
+ queueSilence() {
91
+ silenceQueue += 1;
92
+ },
85
93
  install() {
86
94
  const w = globalThis;
87
95
  hadGoogle = Object.prototype.hasOwnProperty.call(w, 'google');
@@ -110,6 +118,7 @@ export function createGisFake() {
110
118
  responseQueue.length = 0;
111
119
  popupErrorQueue.length = 0;
112
120
  popupClosedRaceQueue.length = 0;
121
+ silenceQueue = 0;
113
122
  calls.length = 0;
114
123
  },
115
124
  };
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
  /**
@@ -101,7 +128,7 @@ export async function acquireToken(opts) {
101
128
  return stored;
102
129
  }
103
130
  }
104
- const key = coalesceKey(opts.projectId, opts.scopes);
131
+ const key = coalesceKey(opts.projectId, opts.scopes, opts.interactive);
105
132
  const existing = inFlight.get(key);
106
133
  if (existing) {
107
134
  return existing;
@@ -140,7 +167,7 @@ async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
140
167
  try {
141
168
  // No grace window: `prompt: 'none'` never opens a popup, so there is no
142
169
  // 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);
170
+ const response = await requestGisToken(initTokenClient, opts, { prompt: 'none', hint: opts.hint }, 0, PROBE_REQUEST_TIMEOUT_MS);
144
171
  opts.logger?.debug('drive-sync: recovered a completed sign-in reported as popup_closed', {
145
172
  projectId: opts.projectId,
146
173
  attempt,
@@ -167,9 +194,33 @@ async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
167
194
  * captured in THIS call's closure only — never on a module-level variable — so
168
195
  * a second concurrent call cannot clobber the first caller's promise.
169
196
  */
170
- function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = POPUP_CLOSED_GRACE_MS) {
197
+ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = POPUP_CLOSED_GRACE_MS, timeoutMs = INTERACTIVE_REQUEST_TIMEOUT_MS) {
171
198
  return new Promise((resolve, reject) => {
172
199
  let settled = false;
200
+ // Neither GIS callback is guaranteed to fire; see the timeout constants.
201
+ const timeout = setTimeout(() => {
202
+ if (settled)
203
+ return;
204
+ settled = true;
205
+ opts.logger?.warn('drive-sync: GIS never returned a result; timing out the request', {
206
+ projectId: opts.projectId,
207
+ prompt: override.prompt,
208
+ timeoutMs,
209
+ });
210
+ reject(new NeedsReauthError('Google sign-in did not return a result', {
211
+ reason: 'gis_timeout',
212
+ }));
213
+ }, timeoutMs);
214
+ const succeed = (res) => {
215
+ settled = true;
216
+ clearTimeout(timeout);
217
+ resolve(res);
218
+ };
219
+ const fail = (err) => {
220
+ settled = true;
221
+ clearTimeout(timeout);
222
+ reject(err);
223
+ };
173
224
  const client = initTokenClient({
174
225
  client_id: opts.clientId,
175
226
  scope: opts.scopes.join(' '),
@@ -185,12 +236,11 @@ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = P
185
236
  });
186
237
  return;
187
238
  }
188
- settled = true;
189
239
  if (res.error) {
190
- reject(new Error(`GIS token request failed: ${res.error}`));
240
+ fail(new Error(`GIS token request failed: ${res.error}`));
191
241
  return;
192
242
  }
193
- resolve(res);
243
+ succeed(res);
194
244
  },
195
245
  // Without this, a popup that the browser blocks or the user closes
196
246
  // settles NOTHING: GIS reports those through error_callback only, so
@@ -216,15 +266,13 @@ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = P
216
266
  setTimeout(() => {
217
267
  if (settled)
218
268
  return;
219
- settled = true;
220
- reject(new NeedsReauthError('Google sign-in popup was closed before completing', {
269
+ fail(new NeedsReauthError('Google sign-in popup was closed before completing', {
221
270
  reason: 'popup_closed',
222
271
  }));
223
272
  }, popupClosedGraceMs);
224
273
  return;
225
274
  }
226
- settled = true;
227
- reject(new NeedsReauthError(err?.type === 'popup_failed_to_open'
275
+ fail(new NeedsReauthError(err?.type === 'popup_failed_to_open'
228
276
  ? 'Google sign-in popup was blocked by the browser'
229
277
  : `Google sign-in failed: ${err?.type ?? 'unknown error'}`, { reason: err?.type ?? 'gis_error' }));
230
278
  },
@@ -262,7 +310,7 @@ async function acquireTokenUncoalesced(opts) {
262
310
  // the chooser too.
263
311
  prompt: opts.interactive ? '' : 'none',
264
312
  hint: opts.hint,
265
- });
313
+ }, POPUP_CLOSED_GRACE_MS, opts.interactive ? INTERACTIVE_REQUEST_TIMEOUT_MS : SILENT_REQUEST_TIMEOUT_MS);
266
314
  }
267
315
  catch (err) {
268
316
  if (!opts.interactive) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/drive-sync",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",