@open-webapp/drive-sync 0.5.7 → 0.7.0
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/README.md +53 -0
- package/SPEC.md +22 -4
- package/dist/connection.d.ts +29 -2
- package/dist/connection.js +83 -4
- package/dist/envelope.d.ts +73 -0
- package/dist/envelope.js +231 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +8 -0
- package/dist/files.d.ts +6 -0
- package/dist/files.js +14 -2
- package/dist/gis.d.ts +26 -0
- package/dist/gis.js +118 -1
- package/dist/http.d.ts +9 -0
- package/dist/http.js +31 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +14 -5
- package/dist/permissions.d.ts +6 -0
- package/dist/permissions.js +4 -0
- package/dist/refresh.d.ts +9 -0
- package/dist/refresh.js +11 -2
- package/dist/storage.d.ts +5 -2
- package/dist/storage.js +14 -0
- package/dist/testing/driveFake.d.ts +16 -0
- package/dist/testing/driveFake.js +2 -0
- package/dist/testing/gisFake.d.ts +44 -0
- package/dist/testing/gisFake.js +53 -0
- package/dist/testing/index.d.ts +3 -1
- package/dist/testing/index.js +1 -0
- package/dist/testing/tokenExchangeFake.d.ts +67 -0
- package/dist/testing/tokenExchangeFake.js +253 -0
- package/dist/token.d.ts +14 -0
- package/dist/token.js +28 -0
- package/dist/types.d.ts +38 -0
- package/package.json +1 -1
package/dist/files.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ interface BaseCallOptions {
|
|
|
9
9
|
interactive?: boolean;
|
|
10
10
|
logger?: Logger;
|
|
11
11
|
fetchEmail?: (accessToken: string) => Promise<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Server-mediated token-exchange endpoint. Forwarded verbatim into every
|
|
14
|
+
* `driveFetch` call so envelope-mode token acquisition / 401 recovery runs
|
|
15
|
+
* through `refreshEnvelope` (see http.ts). Absent for the legacy GIS path.
|
|
16
|
+
*/
|
|
17
|
+
tokenExchangeUrl?: string;
|
|
12
18
|
}
|
|
13
19
|
export interface ReadOptions extends BaseCallOptions {
|
|
14
20
|
fileId: string;
|
package/dist/files.js
CHANGED
|
@@ -26,6 +26,7 @@ async function fetchRemoteVersion(opts) {
|
|
|
26
26
|
requiredScopes: REQUIRED_SCOPES,
|
|
27
27
|
logger: opts.logger,
|
|
28
28
|
fetchEmail: opts.fetchEmail,
|
|
29
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
29
30
|
});
|
|
30
31
|
const json = (await res.json());
|
|
31
32
|
if (json.version === undefined)
|
|
@@ -76,6 +77,7 @@ export async function read(opts) {
|
|
|
76
77
|
requiredScopes: REQUIRED_SCOPES,
|
|
77
78
|
logger: opts.logger,
|
|
78
79
|
fetchEmail: opts.fetchEmail,
|
|
80
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
79
81
|
});
|
|
80
82
|
const contentType = res.headers.get('Content-Type') ?? '';
|
|
81
83
|
const isTextual = contentType === '' ||
|
|
@@ -107,6 +109,7 @@ export async function remove(opts) {
|
|
|
107
109
|
requiredScopes: REQUIRED_SCOPES,
|
|
108
110
|
logger: opts.logger,
|
|
109
111
|
fetchEmail: opts.fetchEmail,
|
|
112
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
110
113
|
});
|
|
111
114
|
// The baseline describes a file that no longer exists; leaving it behind
|
|
112
115
|
// would make a later file reusing this id look spuriously in sync.
|
|
@@ -159,6 +162,7 @@ async function updateContent(opts, fileId, knownRemoteVersion) {
|
|
|
159
162
|
requiredScopes: REQUIRED_SCOPES,
|
|
160
163
|
logger: opts.logger,
|
|
161
164
|
fetchEmail: opts.fetchEmail,
|
|
165
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
162
166
|
});
|
|
163
167
|
const json = (await res.json());
|
|
164
168
|
// The version we just produced becomes the new baseline, so back-to-back
|
|
@@ -199,6 +203,7 @@ export async function write(opts) {
|
|
|
199
203
|
interactive: opts.interactive,
|
|
200
204
|
logger: opts.logger,
|
|
201
205
|
fetchEmail: opts.fetchEmail,
|
|
206
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
202
207
|
});
|
|
203
208
|
if (existing.length > 0) {
|
|
204
209
|
return updateContent(opts, existing[0].id, existing[0].version);
|
|
@@ -235,6 +240,7 @@ export async function write(opts) {
|
|
|
235
240
|
requiredScopes: REQUIRED_SCOPES,
|
|
236
241
|
logger: opts.logger,
|
|
237
242
|
fetchEmail: opts.fetchEmail,
|
|
243
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
238
244
|
});
|
|
239
245
|
const json = (await res.json());
|
|
240
246
|
// This client authored the file, so it starts out fully in sync.
|
|
@@ -296,8 +302,10 @@ export async function list(opts) {
|
|
|
296
302
|
// `version` comes back so the name-resolution path in write() can run its
|
|
297
303
|
// staleness check without a follow-up metadata fetch. `modifiedTime` is
|
|
298
304
|
// also requested so callers can get a last-modified timestamp per file
|
|
299
|
-
// without an extra round trip.
|
|
300
|
-
|
|
305
|
+
// without an extra round trip. `thumbnailLink` +
|
|
306
|
+
// `imageMediaMetadata(width,height,rotation)` are also requested so callers
|
|
307
|
+
// can render/orient low-res image thumbnails without an extra round trip.
|
|
308
|
+
const url = `${DRIVE_BASE}/files?q=${encodeURIComponent(q)}&fields=${encodeURIComponent('files(id,name,mimeType,version,modifiedTime,thumbnailLink,imageMediaMetadata(width,height,rotation))')}`;
|
|
301
309
|
const res = await driveFetch({
|
|
302
310
|
appId: opts.appId,
|
|
303
311
|
projectId: opts.projectId,
|
|
@@ -308,6 +316,7 @@ export async function list(opts) {
|
|
|
308
316
|
requiredScopes: REQUIRED_SCOPES,
|
|
309
317
|
logger: opts.logger,
|
|
310
318
|
fetchEmail: opts.fetchEmail,
|
|
319
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
311
320
|
});
|
|
312
321
|
const json = (await res.json());
|
|
313
322
|
return json.files ?? [];
|
|
@@ -333,6 +342,7 @@ async function createFolder(opts) {
|
|
|
333
342
|
requiredScopes: REQUIRED_SCOPES,
|
|
334
343
|
logger: opts.logger,
|
|
335
344
|
fetchEmail: opts.fetchEmail,
|
|
345
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
336
346
|
});
|
|
337
347
|
const json = (await res.json());
|
|
338
348
|
return json.id;
|
|
@@ -363,6 +373,7 @@ export async function ensureFolderPath(opts) {
|
|
|
363
373
|
interactive: opts.interactive,
|
|
364
374
|
logger: opts.logger,
|
|
365
375
|
fetchEmail: opts.fetchEmail,
|
|
376
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
366
377
|
folderId: parentId,
|
|
367
378
|
mimeType: FOLDER_MIME_TYPE,
|
|
368
379
|
nameEquals: name,
|
|
@@ -378,6 +389,7 @@ export async function ensureFolderPath(opts) {
|
|
|
378
389
|
interactive: opts.interactive,
|
|
379
390
|
logger: opts.logger,
|
|
380
391
|
fetchEmail: opts.fetchEmail,
|
|
392
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
381
393
|
name,
|
|
382
394
|
parentId,
|
|
383
395
|
});
|
package/dist/gis.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Logger } from './logger.js';
|
|
2
|
+
/** Whether `google.accounts.oauth2.initCodeClient` is present and callable. */
|
|
3
|
+
export declare function isCodeClientAvailable(): boolean;
|
|
2
4
|
/**
|
|
3
5
|
* Resolves once `window.google.accounts.oauth2.initTokenClient` becomes
|
|
4
6
|
* available, polling every 100ms. Rejects with a GisLoadError if it does not
|
|
@@ -6,3 +8,27 @@ import type { Logger } from './logger.js';
|
|
|
6
8
|
* correctly under `vi.useFakeTimers()`.
|
|
7
9
|
*/
|
|
8
10
|
export declare function waitForGoogleIdentityServices(logger?: Logger): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Envelope-mode counterpart of {@link waitForGoogleIdentityServices}: resolves
|
|
13
|
+
* once `window.google.accounts.oauth2.initCodeClient` is available, polling
|
|
14
|
+
* every 100ms, rejecting with a GisLoadError after 10 seconds. Kept separate
|
|
15
|
+
* from the legacy token-client poll so the legacy path is untouched.
|
|
16
|
+
*/
|
|
17
|
+
export declare function waitForGisCodeClient(logger?: Logger): Promise<void>;
|
|
18
|
+
export interface AcquireAuthCodeOptions {
|
|
19
|
+
clientId: string;
|
|
20
|
+
scopes: string[];
|
|
21
|
+
/** Prior account email, passed to GIS as `hint` to pre-select the account. */
|
|
22
|
+
hint?: string;
|
|
23
|
+
logger?: Logger;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Runs the GIS auth-code (server-side exchange) popup flow once and resolves
|
|
27
|
+
* with the one-time authorization `code`. No `redirect_uri`, no `state`: the
|
|
28
|
+
* code is handed straight to the token-exchange endpoint.
|
|
29
|
+
*
|
|
30
|
+
* Rejects with a {@link NeedsReauthError} when GIS reports an in-band
|
|
31
|
+
* `response.error`, fires `error_callback` (blocked/dismissed popup), or never
|
|
32
|
+
* answers on either channel within {@link ACQUIRE_CODE_TIMEOUT_MS}.
|
|
33
|
+
*/
|
|
34
|
+
export declare function acquireAuthCode(opts: AcquireAuthCodeOptions): Promise<string>;
|
package/dist/gis.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
import { GisLoadError } from './errors.js';
|
|
1
|
+
import { GisLoadError, NeedsReauthError } from './errors.js';
|
|
2
2
|
const POLL_INTERVAL_MS = 100;
|
|
3
3
|
const TIMEOUT_MS = 10_000;
|
|
4
|
+
/**
|
|
5
|
+
* Hard ceiling on how long `acquireAuthCode` waits for GIS to deliver a result
|
|
6
|
+
* on EITHER channel (`callback` or `error_callback`). Neither is guaranteed to
|
|
7
|
+
* fire — a silently swallowed popup leaves the promise pending forever without
|
|
8
|
+
* this. Mirrors token.ts's `INTERACTIVE_REQUEST_TIMEOUT_MS` (5 minutes): the
|
|
9
|
+
* user may take a while at the Google consent screen.
|
|
10
|
+
*/
|
|
11
|
+
const ACQUIRE_CODE_TIMEOUT_MS = 5 * 60_000;
|
|
4
12
|
function isGisAvailable() {
|
|
5
13
|
const w = globalThis;
|
|
6
14
|
return typeof w.google?.accounts?.oauth2?.initTokenClient !== 'undefined';
|
|
7
15
|
}
|
|
16
|
+
/** Whether `google.accounts.oauth2.initCodeClient` is present and callable. */
|
|
17
|
+
export function isCodeClientAvailable() {
|
|
18
|
+
const w = globalThis;
|
|
19
|
+
return typeof w.google?.accounts?.oauth2?.initCodeClient === 'function';
|
|
20
|
+
}
|
|
8
21
|
/**
|
|
9
22
|
* Resolves once `window.google.accounts.oauth2.initTokenClient` becomes
|
|
10
23
|
* available, polling every 100ms. Rejects with a GisLoadError if it does not
|
|
@@ -31,3 +44,107 @@ export function waitForGoogleIdentityServices(logger) {
|
|
|
31
44
|
}, POLL_INTERVAL_MS);
|
|
32
45
|
});
|
|
33
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Envelope-mode counterpart of {@link waitForGoogleIdentityServices}: resolves
|
|
49
|
+
* once `window.google.accounts.oauth2.initCodeClient` is available, polling
|
|
50
|
+
* every 100ms, rejecting with a GisLoadError after 10 seconds. Kept separate
|
|
51
|
+
* from the legacy token-client poll so the legacy path is untouched.
|
|
52
|
+
*/
|
|
53
|
+
export function waitForGisCodeClient(logger) {
|
|
54
|
+
if (isCodeClientAvailable()) {
|
|
55
|
+
return Promise.resolve();
|
|
56
|
+
}
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const startedAt = Date.now();
|
|
59
|
+
const interval = setInterval(() => {
|
|
60
|
+
logger?.debug('drive-sync: polling for Google Identity Services code client...');
|
|
61
|
+
if (isCodeClientAvailable()) {
|
|
62
|
+
clearInterval(interval);
|
|
63
|
+
resolve();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (Date.now() - startedAt >= TIMEOUT_MS) {
|
|
67
|
+
clearInterval(interval);
|
|
68
|
+
reject(new GisLoadError());
|
|
69
|
+
}
|
|
70
|
+
}, POLL_INTERVAL_MS);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Runs the GIS auth-code (server-side exchange) popup flow once and resolves
|
|
75
|
+
* with the one-time authorization `code`. No `redirect_uri`, no `state`: the
|
|
76
|
+
* code is handed straight to the token-exchange endpoint.
|
|
77
|
+
*
|
|
78
|
+
* Rejects with a {@link NeedsReauthError} when GIS reports an in-band
|
|
79
|
+
* `response.error`, fires `error_callback` (blocked/dismissed popup), or never
|
|
80
|
+
* answers on either channel within {@link ACQUIRE_CODE_TIMEOUT_MS}.
|
|
81
|
+
*/
|
|
82
|
+
export async function acquireAuthCode(opts) {
|
|
83
|
+
await waitForGisCodeClient(opts.logger);
|
|
84
|
+
const w = globalThis;
|
|
85
|
+
const initCodeClient = w.google?.accounts?.oauth2?.initCodeClient;
|
|
86
|
+
if (!initCodeClient) {
|
|
87
|
+
throw new GisLoadError();
|
|
88
|
+
}
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
let settled = false;
|
|
91
|
+
const timeout = setTimeout(() => {
|
|
92
|
+
if (settled)
|
|
93
|
+
return;
|
|
94
|
+
settled = true;
|
|
95
|
+
opts.logger?.warn('drive-sync: GIS never returned an auth code; timing out', {
|
|
96
|
+
timeoutMs: ACQUIRE_CODE_TIMEOUT_MS,
|
|
97
|
+
});
|
|
98
|
+
reject(new NeedsReauthError('Google sign-in did not return an auth code', {
|
|
99
|
+
reason: 'gis_timeout',
|
|
100
|
+
}));
|
|
101
|
+
}, ACQUIRE_CODE_TIMEOUT_MS);
|
|
102
|
+
const succeed = (code) => {
|
|
103
|
+
settled = true;
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
resolve(code);
|
|
106
|
+
};
|
|
107
|
+
const fail = (err) => {
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
reject(err);
|
|
111
|
+
};
|
|
112
|
+
const client = initCodeClient({
|
|
113
|
+
client_id: opts.clientId,
|
|
114
|
+
scope: opts.scopes.join(' '),
|
|
115
|
+
ux_mode: 'popup',
|
|
116
|
+
hint: opts.hint,
|
|
117
|
+
callback: (res) => {
|
|
118
|
+
if (settled)
|
|
119
|
+
return;
|
|
120
|
+
if (res.error) {
|
|
121
|
+
fail(new NeedsReauthError(`Google sign-in failed: ${res.error}`, {
|
|
122
|
+
reason: res.error,
|
|
123
|
+
}));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!res.code) {
|
|
127
|
+
fail(new NeedsReauthError('Google sign-in returned no auth code', {
|
|
128
|
+
reason: 'gis_error',
|
|
129
|
+
}));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
succeed(res.code);
|
|
133
|
+
},
|
|
134
|
+
error_callback: (err) => {
|
|
135
|
+
opts.logger?.debug('drive-sync: GIS code client error_callback', {
|
|
136
|
+
type: err?.type,
|
|
137
|
+
message: err?.message,
|
|
138
|
+
settled,
|
|
139
|
+
});
|
|
140
|
+
if (settled)
|
|
141
|
+
return;
|
|
142
|
+
fail(new NeedsReauthError(err?.type === 'popup_failed_to_open'
|
|
143
|
+
? 'Google sign-in popup was blocked by the browser'
|
|
144
|
+
: `Google sign-in failed: ${err?.type ?? 'unknown error'}`, { reason: err?.type ?? 'gis_error' }));
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
opts.logger?.debug('drive-sync: requesting auth code');
|
|
148
|
+
client.requestCode();
|
|
149
|
+
});
|
|
150
|
+
}
|
package/dist/http.d.ts
CHANGED
|
@@ -20,6 +20,15 @@ export interface DriveFetchOptions {
|
|
|
20
20
|
* this module has no hard dependency on a network implementation.
|
|
21
21
|
*/
|
|
22
22
|
fetchEmail?: (accessToken: string) => Promise<string>;
|
|
23
|
+
/**
|
|
24
|
+
* Server-mediated token-exchange endpoint. When set, drive-sync is in
|
|
25
|
+
* "envelope mode": token acquisition and 401 recovery go through
|
|
26
|
+
* `refreshEnvelope` (envelope.ts) rather than the legacy GIS
|
|
27
|
+
* `acquireToken`/`refreshSilently` path. Drive calls never drive the
|
|
28
|
+
* interactive code popup in this mode — only `connect()` does — so an
|
|
29
|
+
* interactive Drive call with no usable token surfaces `NeedsReauthError`.
|
|
30
|
+
*/
|
|
31
|
+
tokenExchangeUrl?: string;
|
|
23
32
|
}
|
|
24
33
|
/**
|
|
25
34
|
* Single entry point for all Drive API HTTP calls. Handles token
|
package/dist/http.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { acquireToken } from './token.js';
|
|
2
|
+
import { refreshEnvelope } from './envelope.js';
|
|
2
3
|
import { getConnection, refreshSilently } from './connection.js';
|
|
3
4
|
import { clearToken, getToken } from './storage.js';
|
|
4
5
|
import { DriveSyncError, NeedsReauthError, ScopeInsufficientError, NotFoundError, NotDownloadableError, RateLimitedError, TransientError, WrongAccountError, } from './errors.js';
|
|
@@ -60,6 +61,22 @@ export async function driveFetch(opts) {
|
|
|
60
61
|
// NeedsReauthError from acquireToken itself — we deliberately let that
|
|
61
62
|
// propagate rather than pre-emptively short-circuiting here, since
|
|
62
63
|
// acquireToken/GIS is the single source of truth for "can we get a token".
|
|
64
|
+
// Envelope (server-mediated token-exchange) mode: never touch GIS. A
|
|
65
|
+
// non-interactive call refreshes through the stored envelope; an
|
|
66
|
+
// interactive Drive call has no usable token and cannot run the code
|
|
67
|
+
// popup itself (only connect() does) -> NeedsReauthError.
|
|
68
|
+
if (opts.tokenExchangeUrl) {
|
|
69
|
+
if (interactive) {
|
|
70
|
+
throw new NeedsReauthError('Interactive Drive call in token-exchange mode requires connect()', { status: 401, reason: 'exchange_failed' });
|
|
71
|
+
}
|
|
72
|
+
const token = await refreshEnvelope({
|
|
73
|
+
appId,
|
|
74
|
+
projectId,
|
|
75
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
76
|
+
logger,
|
|
77
|
+
});
|
|
78
|
+
return performFetch(opts, token.accessToken, /* isRetryAfter401 */ false);
|
|
79
|
+
}
|
|
63
80
|
const conn = await getConnection({ appId, projectId, requiredScopes });
|
|
64
81
|
const hint = conn?.email;
|
|
65
82
|
const token = await acquireToken({
|
|
@@ -130,6 +147,20 @@ async function performFetch(opts, accessToken, isRetryAfter401) {
|
|
|
130
147
|
reason: lastBodyText,
|
|
131
148
|
});
|
|
132
149
|
}
|
|
150
|
+
// Envelope mode: clear only the token key (per decision 4/8) and recover
|
|
151
|
+
// through the stored envelope. A refreshEnvelope throw — including the 410
|
|
152
|
+
// that also clears conn+envelope — is already a NeedsReauthError; let it
|
|
153
|
+
// propagate as-is.
|
|
154
|
+
if (opts.tokenExchangeUrl) {
|
|
155
|
+
await clearToken(appId, projectId);
|
|
156
|
+
const refreshed = await refreshEnvelope({
|
|
157
|
+
appId,
|
|
158
|
+
projectId,
|
|
159
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
160
|
+
logger,
|
|
161
|
+
});
|
|
162
|
+
return performFetch(opts, refreshed.accessToken, /* isRetryAfter401 */ true);
|
|
163
|
+
}
|
|
133
164
|
await clearToken(appId, projectId);
|
|
134
165
|
let refreshed;
|
|
135
166
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CallOptions, Connection, DriveSyncOptions, DrivePermission, FileRef, FileState, PickFileOptions, PickedFile } from './types.js';
|
|
2
|
-
export type { DriveSyncOptions, Connection, StoredToken, FileRef, FileState, DrivePermission, CallOptions, WorkspaceMimeShorthand, PickFileOptions, PickedFile } from './types.js';
|
|
2
|
+
export type { DriveSyncOptions, Connection, StoredToken, FileRef, FileState, DrivePermission, CallOptions, WorkspaceMimeShorthand, PickFileOptions, PickedFile, Envelope, EnvelopePayload } from './types.js';
|
|
3
3
|
export * from './errors.js';
|
|
4
4
|
export interface FilesHandle {
|
|
5
5
|
list(opts?: {
|
package/dist/index.js
CHANGED
|
@@ -47,7 +47,7 @@ async function revokeToken(accessToken) {
|
|
|
47
47
|
* returned object's methods are actually called.
|
|
48
48
|
*/
|
|
49
49
|
export function createDriveSync(options) {
|
|
50
|
-
const { appId, clientId, folderPath } = options;
|
|
50
|
+
const { appId, clientId, folderPath, tokenExchangeUrl } = options;
|
|
51
51
|
const logger = options.logger ?? noOpLogger;
|
|
52
52
|
/**
|
|
53
53
|
* Design choice for `.activate()` (T30): the frozen public API's
|
|
@@ -99,7 +99,7 @@ export function createDriveSync(options) {
|
|
|
99
99
|
}
|
|
100
100
|
const runWarmUps = () => {
|
|
101
101
|
for (const projectId of trackedProjectIds) {
|
|
102
|
-
void warmUpIfNeeded({ appId, projectId, clientId, fetchEmail, logger });
|
|
102
|
+
void warmUpIfNeeded({ appId, projectId, clientId, tokenExchangeUrl, fetchEmail, logger });
|
|
103
103
|
}
|
|
104
104
|
};
|
|
105
105
|
const onVisibilityChange = () => {
|
|
@@ -131,7 +131,7 @@ export function createDriveSync(options) {
|
|
|
131
131
|
}
|
|
132
132
|
function project(projectId) {
|
|
133
133
|
trackProject(projectId);
|
|
134
|
-
const base = { appId, projectId, clientId, logger, fetchEmail };
|
|
134
|
+
const base = { appId, projectId, clientId, tokenExchangeUrl, logger, fetchEmail };
|
|
135
135
|
const files = {
|
|
136
136
|
list(opts, callOpts) {
|
|
137
137
|
return filesImpl.list({ ...base, ...opts, interactive: callOpts?.interactive });
|
|
@@ -170,6 +170,7 @@ export function createDriveSync(options) {
|
|
|
170
170
|
projectId,
|
|
171
171
|
clientId,
|
|
172
172
|
scopes: REQUIRED_SCOPES,
|
|
173
|
+
tokenExchangeUrl,
|
|
173
174
|
logger,
|
|
174
175
|
fetchEmail,
|
|
175
176
|
});
|
|
@@ -182,9 +183,14 @@ export function createDriveSync(options) {
|
|
|
182
183
|
});
|
|
183
184
|
},
|
|
184
185
|
async disconnect() {
|
|
185
|
-
|
|
186
|
+
// `tokenExchangeUrl` is threaded via an intermediate object rather
|
|
187
|
+
// than an inline literal so this compiles while T7's
|
|
188
|
+
// `DisconnectOptions.tokenExchangeUrl?` field lands in parallel; once
|
|
189
|
+
// it has, the envelope-mode disconnect branch picks the value up.
|
|
190
|
+
const disconnectOpts = {
|
|
186
191
|
appId,
|
|
187
192
|
projectId,
|
|
193
|
+
tokenExchangeUrl,
|
|
188
194
|
revokeFn: async (accessToken) => {
|
|
189
195
|
try {
|
|
190
196
|
await revokeToken(accessToken);
|
|
@@ -193,7 +199,8 @@ export function createDriveSync(options) {
|
|
|
193
199
|
logger.warn('drive-sync: token revocation failed', { err });
|
|
194
200
|
}
|
|
195
201
|
},
|
|
196
|
-
}
|
|
202
|
+
};
|
|
203
|
+
await disconnectImpl(disconnectOpts);
|
|
197
204
|
},
|
|
198
205
|
ensureFolderPath() {
|
|
199
206
|
return filesImpl.ensureFolderPath({ ...base, folderPath });
|
|
@@ -205,6 +212,7 @@ export function createDriveSync(options) {
|
|
|
205
212
|
clientId,
|
|
206
213
|
scopes: REQUIRED_SCOPES,
|
|
207
214
|
interactive: callOpts?.interactive ?? true,
|
|
215
|
+
tokenExchangeUrl,
|
|
208
216
|
logger,
|
|
209
217
|
});
|
|
210
218
|
},
|
|
@@ -215,6 +223,7 @@ export function createDriveSync(options) {
|
|
|
215
223
|
clientId,
|
|
216
224
|
scopes: REQUIRED_SCOPES,
|
|
217
225
|
interactive: true,
|
|
226
|
+
tokenExchangeUrl,
|
|
218
227
|
logger,
|
|
219
228
|
});
|
|
220
229
|
const picked = await pickerImpl.openPicker({
|
package/dist/permissions.d.ts
CHANGED
|
@@ -7,6 +7,12 @@ interface BaseCallOptions {
|
|
|
7
7
|
interactive?: boolean;
|
|
8
8
|
logger?: Logger;
|
|
9
9
|
fetchEmail?: (accessToken: string) => Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Server-mediated token-exchange endpoint. Forwarded verbatim into every
|
|
12
|
+
* `driveFetch` call so envelope-mode token acquisition / 401 recovery runs
|
|
13
|
+
* through `refreshEnvelope` (see http.ts). Absent for the legacy GIS path.
|
|
14
|
+
*/
|
|
15
|
+
tokenExchangeUrl?: string;
|
|
10
16
|
}
|
|
11
17
|
export interface ListPermissionsOptions extends BaseCallOptions {
|
|
12
18
|
fileId: string;
|
package/dist/permissions.js
CHANGED
|
@@ -12,6 +12,7 @@ export async function list(opts) {
|
|
|
12
12
|
requiredScopes: REQUIRED_SCOPES,
|
|
13
13
|
logger: opts.logger,
|
|
14
14
|
fetchEmail: opts.fetchEmail,
|
|
15
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
15
16
|
});
|
|
16
17
|
const json = (await res.json());
|
|
17
18
|
return json.permissions ?? [];
|
|
@@ -37,6 +38,7 @@ export async function grant(opts) {
|
|
|
37
38
|
requiredScopes: REQUIRED_SCOPES,
|
|
38
39
|
logger: opts.logger,
|
|
39
40
|
fetchEmail: opts.fetchEmail,
|
|
41
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
40
42
|
});
|
|
41
43
|
return (await res.json());
|
|
42
44
|
}
|
|
@@ -53,6 +55,7 @@ export async function update(opts) {
|
|
|
53
55
|
requiredScopes: REQUIRED_SCOPES,
|
|
54
56
|
logger: opts.logger,
|
|
55
57
|
fetchEmail: opts.fetchEmail,
|
|
58
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
56
59
|
});
|
|
57
60
|
return (await res.json());
|
|
58
61
|
}
|
|
@@ -67,5 +70,6 @@ export async function revoke(opts) {
|
|
|
67
70
|
requiredScopes: REQUIRED_SCOPES,
|
|
68
71
|
logger: opts.logger,
|
|
69
72
|
fetchEmail: opts.fetchEmail,
|
|
73
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
70
74
|
});
|
|
71
75
|
}
|
package/dist/refresh.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Logger } from './logger.js';
|
|
2
|
+
export declare const REFRESH_BUFFER_MS: number;
|
|
2
3
|
export interface ActivateOptions {
|
|
3
4
|
appId: string;
|
|
4
5
|
projectId: string;
|
|
@@ -11,6 +12,14 @@ export interface ActivateOptions {
|
|
|
11
12
|
* only being discovered on the next foreground Drive call.
|
|
12
13
|
*/
|
|
13
14
|
fetchEmail?: (accessToken: string) => Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Server-mediated token-exchange endpoint. When set, the warm-up's
|
|
17
|
+
* non-interactive refresh goes through `refreshEnvelope` (envelope.ts) —
|
|
18
|
+
* replaying the stored envelope against this endpoint — instead of the
|
|
19
|
+
* legacy GIS `refreshSilently` / `acquireToken` path. Absent, the legacy
|
|
20
|
+
* path runs unchanged.
|
|
21
|
+
*/
|
|
22
|
+
tokenExchangeUrl?: string;
|
|
14
23
|
logger?: Logger;
|
|
15
24
|
}
|
|
16
25
|
/**
|
package/dist/refresh.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { getConnection, refreshSilently } from './connection.js';
|
|
2
2
|
import { getToken } from './storage.js';
|
|
3
3
|
import { acquireToken } from './token.js';
|
|
4
|
+
import { refreshEnvelope } from './envelope.js';
|
|
4
5
|
import { REQUIRED_SCOPES } from './files.js';
|
|
5
|
-
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
6
|
+
export const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
6
7
|
/**
|
|
7
8
|
* Per-project token warm-up check: if a connection exists AND its cached
|
|
8
9
|
* token is missing or within the refresh buffer of expiring, fires a
|
|
@@ -29,7 +30,15 @@ export async function warmUpIfNeeded(opts) {
|
|
|
29
30
|
if (!isStale) {
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
32
|
-
if (opts.
|
|
33
|
+
if (opts.tokenExchangeUrl) {
|
|
34
|
+
await refreshEnvelope({
|
|
35
|
+
appId: opts.appId,
|
|
36
|
+
projectId: opts.projectId,
|
|
37
|
+
tokenExchangeUrl: opts.tokenExchangeUrl,
|
|
38
|
+
logger: opts.logger,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else if (opts.fetchEmail) {
|
|
33
42
|
await refreshSilently({
|
|
34
43
|
appId: opts.appId,
|
|
35
44
|
projectId: opts.projectId,
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type IDBPDatabase, type DBSchema } from 'idb';
|
|
2
|
-
import type { StoredToken } from './types.js';
|
|
2
|
+
import type { Envelope, StoredToken } from './types.js';
|
|
3
3
|
/** Durable connection record persisted under the 'conn' key of the auth store. */
|
|
4
4
|
export interface ConnRecord {
|
|
5
5
|
email: string;
|
|
@@ -20,7 +20,7 @@ export interface FileStateRecord {
|
|
|
20
20
|
interface AuthDbSchema extends DBSchema {
|
|
21
21
|
auth: {
|
|
22
22
|
key: string;
|
|
23
|
-
value: ConnRecord | StoredToken | FileStateRecord;
|
|
23
|
+
value: ConnRecord | StoredToken | Envelope | FileStateRecord;
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
export declare function openAuthDb(appId: string, projectId: string): Promise<IDBPDatabase<AuthDbSchema>>;
|
|
@@ -35,6 +35,9 @@ export declare function clearConn(appId: string, projectId: string): Promise<voi
|
|
|
35
35
|
export declare function getToken(appId: string, projectId: string): Promise<StoredToken | undefined>;
|
|
36
36
|
export declare function setToken(appId: string, projectId: string, token: StoredToken): Promise<void>;
|
|
37
37
|
export declare function clearToken(appId: string, projectId: string): Promise<void>;
|
|
38
|
+
export declare function getEnvelope(appId: string, projectId: string): Promise<Envelope | undefined>;
|
|
39
|
+
export declare function setEnvelope(appId: string, projectId: string, env: Envelope): Promise<void>;
|
|
40
|
+
export declare function clearEnvelope(appId: string, projectId: string): Promise<void>;
|
|
38
41
|
export declare function getFileState(appId: string, projectId: string, fileId: string): Promise<FileStateRecord | undefined>;
|
|
39
42
|
export declare function setFileState(appId: string, projectId: string, state: FileStateRecord): Promise<void>;
|
|
40
43
|
export declare function clearFileState(appId: string, projectId: string, fileId: string): Promise<void>;
|
package/dist/storage.js
CHANGED
|
@@ -2,6 +2,7 @@ import { openDB } from 'idb';
|
|
|
2
2
|
const AUTH_STORE = 'auth';
|
|
3
3
|
const CONN_KEY = 'conn';
|
|
4
4
|
const TOKEN_KEY = 'token';
|
|
5
|
+
const ENVELOPE_KEY = 'envelope';
|
|
5
6
|
/**
|
|
6
7
|
* File baselines live in the existing 'auth' store under a namespaced key
|
|
7
8
|
* rather than in a store of their own, deliberately: adding a store means
|
|
@@ -85,6 +86,19 @@ export async function clearToken(appId, projectId) {
|
|
|
85
86
|
const db = await openAuthDb(appId, projectId);
|
|
86
87
|
await db.delete(AUTH_STORE, TOKEN_KEY);
|
|
87
88
|
}
|
|
89
|
+
export async function getEnvelope(appId, projectId) {
|
|
90
|
+
const db = await openAuthDb(appId, projectId);
|
|
91
|
+
const value = await db.get(AUTH_STORE, ENVELOPE_KEY);
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
export async function setEnvelope(appId, projectId, env) {
|
|
95
|
+
const db = await openAuthDb(appId, projectId);
|
|
96
|
+
await db.put(AUTH_STORE, env, ENVELOPE_KEY);
|
|
97
|
+
}
|
|
98
|
+
export async function clearEnvelope(appId, projectId) {
|
|
99
|
+
const db = await openAuthDb(appId, projectId);
|
|
100
|
+
await db.delete(AUTH_STORE, ENVELOPE_KEY);
|
|
101
|
+
}
|
|
88
102
|
export async function getFileState(appId, projectId, fileId) {
|
|
89
103
|
const db = await openAuthDb(appId, projectId);
|
|
90
104
|
const value = await db.get(AUTH_STORE, fileKey(fileId));
|
|
@@ -31,6 +31,22 @@ export interface DriveFakeFile {
|
|
|
31
31
|
* real Drive's `fields`-gated behavior).
|
|
32
32
|
*/
|
|
33
33
|
modifiedTime?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Drive's CDN thumbnail URL; optional so tests may seed files without it
|
|
36
|
+
* (omitted from the fake's response in that case, matching real Drive's
|
|
37
|
+
* `fields`-gated behavior).
|
|
38
|
+
*/
|
|
39
|
+
thumbnailLink?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Drive's image metadata (dimensions/rotation); optional so tests may seed
|
|
42
|
+
* files without it (omitted from the fake's response in that case, matching
|
|
43
|
+
* real Drive's `fields`-gated behavior).
|
|
44
|
+
*/
|
|
45
|
+
imageMediaMetadata?: {
|
|
46
|
+
width?: number;
|
|
47
|
+
height?: number;
|
|
48
|
+
rotation?: number;
|
|
49
|
+
};
|
|
34
50
|
}
|
|
35
51
|
export interface DriveFakePermission {
|
|
36
52
|
id: string;
|
|
@@ -149,6 +149,8 @@ export function createDriveFake() {
|
|
|
149
149
|
parents: f.parents,
|
|
150
150
|
version: String(f.version ?? 1),
|
|
151
151
|
modifiedTime: f.modifiedTime,
|
|
152
|
+
thumbnailLink: f.thumbnailLink,
|
|
153
|
+
imageMediaMetadata: f.imageMediaMetadata,
|
|
152
154
|
};
|
|
153
155
|
}
|
|
154
156
|
async function handleFilesList(url) {
|
|
@@ -45,11 +45,55 @@ export interface GisRequestAccessTokenOverride {
|
|
|
45
45
|
export interface GisTokenClient {
|
|
46
46
|
requestAccessToken(overrideConfig?: GisRequestAccessTokenOverride): void;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Response delivered to an `initCodeClient` callback — the auth-code
|
|
50
|
+
* (server-side / PKCE) flow. Mirrors the shape the real GIS code client
|
|
51
|
+
* passes: `{ code }` on success, `{ error }` on an in-band failure.
|
|
52
|
+
*/
|
|
53
|
+
export interface GisCodeResponse {
|
|
54
|
+
code?: string;
|
|
55
|
+
error?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface GisCodeRecordedCall {
|
|
58
|
+
scope: string;
|
|
59
|
+
hint?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface GisCodeClientConfig {
|
|
62
|
+
client_id?: string;
|
|
63
|
+
scope?: string;
|
|
64
|
+
hint?: string;
|
|
65
|
+
ux_mode?: string;
|
|
66
|
+
redirect_uri?: string;
|
|
67
|
+
callback?: (response: GisCodeResponse) => void;
|
|
68
|
+
error_callback?: (error: {
|
|
69
|
+
type?: string;
|
|
70
|
+
message?: string;
|
|
71
|
+
}) => void;
|
|
72
|
+
[key: string]: unknown;
|
|
73
|
+
}
|
|
74
|
+
export interface GisCodeClient {
|
|
75
|
+
requestCode(): void;
|
|
76
|
+
}
|
|
48
77
|
export interface GisFake {
|
|
49
78
|
/** All calls made via `requestAccessToken`, in order. */
|
|
50
79
|
calls: GisRecordedCall[];
|
|
80
|
+
/** All calls made via the code client's `requestCode`, in order. */
|
|
81
|
+
codeCalls: GisCodeRecordedCall[];
|
|
51
82
|
/** Queue a response to be delivered to the next `requestAccessToken` call. */
|
|
52
83
|
queueResponse(response: GisTokenResponse): void;
|
|
84
|
+
/**
|
|
85
|
+
* Queue a response for the next code-client `requestCode()` call, delivered
|
|
86
|
+
* via `callback` — `{ code }` on success or `{ error }` for an in-band
|
|
87
|
+
* failure. With nothing queued, `requestCode()` yields `{ code: 'fake-auth-code' }`.
|
|
88
|
+
*/
|
|
89
|
+
queueCodeResponse(response: GisCodeResponse): void;
|
|
90
|
+
/**
|
|
91
|
+
* Queue a popup-level failure for the next `requestCode()` call, delivered
|
|
92
|
+
* via `error_callback` (e.g. `'popup_closed'`) — the channel the real GIS
|
|
93
|
+
* code client uses for a blocked/dismissed popup, which never reaches
|
|
94
|
+
* `callback`.
|
|
95
|
+
*/
|
|
96
|
+
queueCodeError(type: string): void;
|
|
53
97
|
/**
|
|
54
98
|
* Queue a popup-level failure for the next `requestAccessToken` call,
|
|
55
99
|
* delivered via `error_callback` — the channel the real GIS client uses
|