@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/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
- const url = `${DRIVE_BASE}/files?q=${encodeURIComponent(q)}&fields=${encodeURIComponent('files(id,name,mimeType,version,modifiedTime)')}`;
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
- await disconnectImpl({
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({
@@ -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;
@@ -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.fetchEmail) {
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