@open-webapp/drive-sync 0.5.6 → 0.6.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/errors.js CHANGED
@@ -17,6 +17,14 @@ export class DriveSyncError extends Error {
17
17
  /**
18
18
  * Thrown when no usable token exists and the requested call is
19
19
  * non-interactive (so no popup/redirect flow may be triggered to obtain one).
20
+ *
21
+ * Known `reason` values:
22
+ * - `popup_closed`, `gis_timeout`, `gis_error` — legacy GIS implicit flow (token.ts).
23
+ * - `refresh_token_revoked` — server-mediated exchange, 410; carried by the
24
+ * `EnvelopeRevokedError` subclass (envelope.ts). Caller must additionally
25
+ * clear conn+token+envelope.
26
+ * - `exchange_failed` — server-mediated exchange, 400/401/404; not retryable.
27
+ * - `exchange_unavailable` — server-mediated exchange, still 502/5xx after retries.
20
28
  */
21
29
  export class NeedsReauthError extends DriveSyncError {
22
30
  constructor(message = 'Reauthentication required', opts) {
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.
@@ -308,6 +314,7 @@ export async function list(opts) {
308
314
  requiredScopes: REQUIRED_SCOPES,
309
315
  logger: opts.logger,
310
316
  fetchEmail: opts.fetchEmail,
317
+ tokenExchangeUrl: opts.tokenExchangeUrl,
311
318
  });
312
319
  const json = (await res.json());
313
320
  return json.files ?? [];
@@ -333,6 +340,7 @@ async function createFolder(opts) {
333
340
  requiredScopes: REQUIRED_SCOPES,
334
341
  logger: opts.logger,
335
342
  fetchEmail: opts.fetchEmail,
343
+ tokenExchangeUrl: opts.tokenExchangeUrl,
336
344
  });
337
345
  const json = (await res.json());
338
346
  return json.id;
@@ -363,6 +371,7 @@ export async function ensureFolderPath(opts) {
363
371
  interactive: opts.interactive,
364
372
  logger: opts.logger,
365
373
  fetchEmail: opts.fetchEmail,
374
+ tokenExchangeUrl: opts.tokenExchangeUrl,
366
375
  folderId: parentId,
367
376
  mimeType: FOLDER_MIME_TYPE,
368
377
  nameEquals: name,
@@ -378,6 +387,7 @@ export async function ensureFolderPath(opts) {
378
387
  interactive: opts.interactive,
379
388
  logger: opts.logger,
380
389
  fetchEmail: opts.fetchEmail,
390
+ tokenExchangeUrl: opts.tokenExchangeUrl,
381
391
  name,
382
392
  parentId,
383
393
  });
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));
@@ -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
@@ -62,6 +106,13 @@ export interface GisFake {
62
106
  * the popup-closed poll fires before the success message is delivered.
63
107
  */
64
108
  queuePopupClosedRace(response: GisTokenResponse, delayMs: number): void;
109
+ /**
110
+ * Queue a request that GIS never answers at all: neither `callback` nor
111
+ * `error_callback` is ever invoked. This is the real-world shape of a flow
112
+ * whose result is never posted back to the page (e.g. a silent
113
+ * `prompt: 'none'` request in a browser that blocks silent token issuance).
114
+ */
115
+ queueSilence(): void;
65
116
  /** Stub `window.google.accounts.oauth2.initTokenClient` with this fake. */
66
117
  install(): void;
67
118
  /** Remove the stub installed by `install()`, restoring prior state. */