@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 +4 -3
- package/dist/testing/gisFake.d.ts +7 -0
- package/dist/testing/gisFake.js +9 -0
- package/dist/token.js +64 -16
- package/package.json +1 -1
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.
|
|
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. */
|
package/dist/testing/gisFake.js
CHANGED
|
@@ -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
|
|
45
|
-
* global. This is what keeps concurrent calls for different
|
|
46
|
-
* different scope requirements within the same project) from
|
|
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
|
-
|
|
240
|
+
fail(new Error(`GIS token request failed: ${res.error}`));
|
|
191
241
|
return;
|
|
192
242
|
}
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|