@open-webapp/drive-sync 0.5.5 → 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/connection.d.ts +3 -2
- package/dist/connection.js +3 -2
- package/dist/testing/gisFake.d.ts +7 -0
- package/dist/testing/gisFake.js +9 -0
- package/dist/token.d.ts +4 -3
- package/dist/token.js +81 -21
- 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]
|
package/dist/connection.d.ts
CHANGED
|
@@ -15,8 +15,9 @@ export interface ConnectOptions {
|
|
|
15
15
|
fetchEmail: (accessToken: string) => Promise<string>;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
* Interactive connection flow: acquires a token
|
|
19
|
-
* resolves the account email, and persists the durable Connection
|
|
18
|
+
* Interactive connection flow: acquires a token without forcing a consent
|
|
19
|
+
* screen, resolves the account email, and persists the durable Connection
|
|
20
|
+
* record.
|
|
20
21
|
*/
|
|
21
22
|
export declare function connect(opts: ConnectOptions): Promise<Connection>;
|
|
22
23
|
export interface RefreshSilentlyOptions {
|
package/dist/connection.js
CHANGED
|
@@ -5,8 +5,9 @@ import { WrongAccountError } from './errors.js';
|
|
|
5
5
|
/** Mirrors refresh.ts's own buffer: a cached token this close to expiry is treated as unusable. */
|
|
6
6
|
const TOKEN_REUSE_BUFFER_MS = 5 * 60 * 1000;
|
|
7
7
|
/**
|
|
8
|
-
* Interactive connection flow: acquires a token
|
|
9
|
-
* resolves the account email, and persists the durable Connection
|
|
8
|
+
* Interactive connection flow: acquires a token without forcing a consent
|
|
9
|
+
* screen, resolves the account email, and persists the durable Connection
|
|
10
|
+
* record.
|
|
10
11
|
*/
|
|
11
12
|
export async function connect(opts) {
|
|
12
13
|
// On a re-auth the previous connection's email is the account the user is
|
|
@@ -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.d.ts
CHANGED
|
@@ -33,9 +33,10 @@ export interface AcquireTokenOptions {
|
|
|
33
33
|
export declare function notifyExternalTokenRefresh(projectId: string): void;
|
|
34
34
|
/**
|
|
35
35
|
* Single entry point for acquiring a Drive access token, used by BOTH the
|
|
36
|
-
* interactive "connect" path (interactive: true -> prompt: '
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* interactive "connect" path (interactive: true -> prompt: '', i.e. no
|
|
37
|
+
* forced consent screen) and the silent "refresh" path (interactive: false
|
|
38
|
+
* -> prompt: 'none'). Both pass `hint`: the connection's known email, when
|
|
39
|
+
* there is one.
|
|
39
40
|
*
|
|
40
41
|
* Design contract (see report): if `interactive` is false and GIS reports an
|
|
41
42
|
* error on the silent attempt, this function throws a NeedsReauthError
|
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
|
/**
|
|
@@ -71,9 +98,10 @@ export function notifyExternalTokenRefresh(projectId) {
|
|
|
71
98
|
}
|
|
72
99
|
/**
|
|
73
100
|
* Single entry point for acquiring a Drive access token, used by BOTH the
|
|
74
|
-
* interactive "connect" path (interactive: true -> prompt: '
|
|
75
|
-
*
|
|
76
|
-
*
|
|
101
|
+
* interactive "connect" path (interactive: true -> prompt: '', i.e. no
|
|
102
|
+
* forced consent screen) and the silent "refresh" path (interactive: false
|
|
103
|
+
* -> prompt: 'none'). Both pass `hint`: the connection's known email, when
|
|
104
|
+
* there is one.
|
|
77
105
|
*
|
|
78
106
|
* Design contract (see report): if `interactive` is false and GIS reports an
|
|
79
107
|
* error on the silent attempt, this function throws a NeedsReauthError
|
|
@@ -100,7 +128,7 @@ export async function acquireToken(opts) {
|
|
|
100
128
|
return stored;
|
|
101
129
|
}
|
|
102
130
|
}
|
|
103
|
-
const key = coalesceKey(opts.projectId, opts.scopes);
|
|
131
|
+
const key = coalesceKey(opts.projectId, opts.scopes, opts.interactive);
|
|
104
132
|
const existing = inFlight.get(key);
|
|
105
133
|
if (existing) {
|
|
106
134
|
return existing;
|
|
@@ -139,7 +167,7 @@ async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
|
|
|
139
167
|
try {
|
|
140
168
|
// No grace window: `prompt: 'none'` never opens a popup, so there is no
|
|
141
169
|
// popup-closed poll to race and nothing to wait out on failure.
|
|
142
|
-
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);
|
|
143
171
|
opts.logger?.debug('drive-sync: recovered a completed sign-in reported as popup_closed', {
|
|
144
172
|
projectId: opts.projectId,
|
|
145
173
|
attempt,
|
|
@@ -166,9 +194,33 @@ async function probeForCompletedGrant(initTokenClient, opts, popupClosedError) {
|
|
|
166
194
|
* captured in THIS call's closure only — never on a module-level variable — so
|
|
167
195
|
* a second concurrent call cannot clobber the first caller's promise.
|
|
168
196
|
*/
|
|
169
|
-
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) {
|
|
170
198
|
return new Promise((resolve, reject) => {
|
|
171
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
|
+
};
|
|
172
224
|
const client = initTokenClient({
|
|
173
225
|
client_id: opts.clientId,
|
|
174
226
|
scope: opts.scopes.join(' '),
|
|
@@ -184,12 +236,11 @@ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = P
|
|
|
184
236
|
});
|
|
185
237
|
return;
|
|
186
238
|
}
|
|
187
|
-
settled = true;
|
|
188
239
|
if (res.error) {
|
|
189
|
-
|
|
240
|
+
fail(new Error(`GIS token request failed: ${res.error}`));
|
|
190
241
|
return;
|
|
191
242
|
}
|
|
192
|
-
|
|
243
|
+
succeed(res);
|
|
193
244
|
},
|
|
194
245
|
// Without this, a popup that the browser blocks or the user closes
|
|
195
246
|
// settles NOTHING: GIS reports those through error_callback only, so
|
|
@@ -215,15 +266,13 @@ function requestGisToken(initTokenClient, opts, override, popupClosedGraceMs = P
|
|
|
215
266
|
setTimeout(() => {
|
|
216
267
|
if (settled)
|
|
217
268
|
return;
|
|
218
|
-
|
|
219
|
-
reject(new NeedsReauthError('Google sign-in popup was closed before completing', {
|
|
269
|
+
fail(new NeedsReauthError('Google sign-in popup was closed before completing', {
|
|
220
270
|
reason: 'popup_closed',
|
|
221
271
|
}));
|
|
222
272
|
}, popupClosedGraceMs);
|
|
223
273
|
return;
|
|
224
274
|
}
|
|
225
|
-
|
|
226
|
-
reject(new NeedsReauthError(err?.type === 'popup_failed_to_open'
|
|
275
|
+
fail(new NeedsReauthError(err?.type === 'popup_failed_to_open'
|
|
227
276
|
? 'Google sign-in popup was blocked by the browser'
|
|
228
277
|
: `Google sign-in failed: ${err?.type ?? 'unknown error'}`, { reason: err?.type ?? 'gis_error' }));
|
|
229
278
|
},
|
|
@@ -248,9 +297,20 @@ async function acquireTokenUncoalesced(opts) {
|
|
|
248
297
|
let response;
|
|
249
298
|
try {
|
|
250
299
|
response = await requestGisToken(initTokenClient, opts, {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
300
|
+
// The interactive path deliberately does NOT force `prompt: 'consent'`.
|
|
301
|
+
// Forcing the full consent screen on every connect buys nothing in the
|
|
302
|
+
// implicit (token) flow — there is no refresh token to obtain — while
|
|
303
|
+
// holding a popup open for seconds. That popup lifetime IS the window
|
|
304
|
+
// in which GIS's popup-closed poll beats delivery of the token, so
|
|
305
|
+
// forcing consent manufactures the very race the probe below recovers
|
|
306
|
+
// from. `prompt: ''` lets Google skip straight through when the grant
|
|
307
|
+
// already exists (and still shows consent on the first grant, or when
|
|
308
|
+
// new scopes are requested), which closes the race instead of racing
|
|
309
|
+
// it. The hint goes on both paths so an already-known account can skip
|
|
310
|
+
// the chooser too.
|
|
311
|
+
prompt: opts.interactive ? '' : 'none',
|
|
312
|
+
hint: opts.hint,
|
|
313
|
+
}, POPUP_CLOSED_GRACE_MS, opts.interactive ? INTERACTIVE_REQUEST_TIMEOUT_MS : SILENT_REQUEST_TIMEOUT_MS);
|
|
254
314
|
}
|
|
255
315
|
catch (err) {
|
|
256
316
|
if (!opts.interactive) {
|