@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.
@@ -19,6 +19,9 @@ export function createGisFake() {
19
19
  const popupClosedRaceQueue = [];
20
20
  let silenceQueue = 0;
21
21
  const calls = [];
22
+ const codeResponseQueue = [];
23
+ const codeErrorQueue = [];
24
+ const codeCalls = [];
22
25
  let previousGoogle;
23
26
  let hadGoogle = false;
24
27
  function nextResponse() {
@@ -28,6 +31,45 @@ export function createGisFake() {
28
31
  // Default: a generic successful token response.
29
32
  return { access_token: 'fake-access-token', expires_in: 3600, scope: '' };
30
33
  }
34
+ function nextCodeResponse() {
35
+ const next = codeResponseQueue.shift();
36
+ if (next)
37
+ return next;
38
+ // Default: a generic successful auth-code response.
39
+ return { code: 'fake-auth-code' };
40
+ }
41
+ function initCodeClient(config) {
42
+ return {
43
+ requestCode() {
44
+ const hint = config.hint;
45
+ const scope = config.scope ?? '';
46
+ codeCalls.push({ scope, hint });
47
+ if (silenceQueue > 0) {
48
+ silenceQueue -= 1;
49
+ return;
50
+ }
51
+ const codeError = codeErrorQueue.shift();
52
+ if (codeError) {
53
+ const errorCallback = config.error_callback;
54
+ queueMicrotask(() => {
55
+ errorCallback?.({ type: codeError });
56
+ });
57
+ return;
58
+ }
59
+ const response = nextCodeResponse();
60
+ const callback = config.callback;
61
+ // Deliver asynchronously (microtask), matching the real GIS code
62
+ // client's callback-based, non-synchronous delivery.
63
+ queueMicrotask(() => {
64
+ if (response.error) {
65
+ callback?.({ error: response.error });
66
+ return;
67
+ }
68
+ callback?.(response);
69
+ });
70
+ },
71
+ };
72
+ }
31
73
  function initTokenClient(config) {
32
74
  return {
33
75
  requestAccessToken(overrideConfig) {
@@ -78,9 +120,16 @@ export function createGisFake() {
78
120
  }
79
121
  return {
80
122
  calls,
123
+ codeCalls,
81
124
  queueResponse(response) {
82
125
  responseQueue.push(response);
83
126
  },
127
+ queueCodeResponse(response) {
128
+ codeResponseQueue.push(response);
129
+ },
130
+ queueCodeError(type) {
131
+ codeErrorQueue.push(type);
132
+ },
84
133
  queuePopupError(type) {
85
134
  popupErrorQueue.push(type);
86
135
  },
@@ -104,6 +153,7 @@ export function createGisFake() {
104
153
  w.google.accounts.oauth2 = {};
105
154
  }
106
155
  w.google.accounts.oauth2.initTokenClient = initTokenClient;
156
+ w.google.accounts.oauth2.initCodeClient = initCodeClient;
107
157
  },
108
158
  uninstall() {
109
159
  const w = globalThis;
@@ -120,6 +170,9 @@ export function createGisFake() {
120
170
  popupClosedRaceQueue.length = 0;
121
171
  silenceQueue = 0;
122
172
  calls.length = 0;
173
+ codeResponseQueue.length = 0;
174
+ codeErrorQueue.length = 0;
175
+ codeCalls.length = 0;
123
176
  },
124
177
  };
125
178
  }
@@ -1,6 +1,8 @@
1
1
  export { createGisFake } from './gisFake.js';
2
- export type { GisFake, GisTokenResponse, GisRecordedCall } from './gisFake.js';
2
+ export type { GisFake, GisTokenResponse, GisRecordedCall, GisCodeResponse, GisCodeRecordedCall, GisCodeClientConfig, GisCodeClient, } from './gisFake.js';
3
3
  export { createDriveFake } from './driveFake.js';
4
4
  export type { DriveFake, DriveFakeFile, DriveFakePermission, StatusOverrideOptions } from './driveFake.js';
5
5
  export { createPickerFake } from './pickerFake.js';
6
6
  export type { PickerFake, PickerFakeFile, PickerRecordedCall } from './pickerFake.js';
7
+ export { createTokenExchangeFake } from './tokenExchangeFake.js';
8
+ export type { TokenExchangeFake, TokenExchangeRecordedCall, CreateTokenExchangeFakeOptions, } from './tokenExchangeFake.js';
@@ -1,3 +1,4 @@
1
1
  export { createGisFake } from './gisFake.js';
2
2
  export { createDriveFake } from './driveFake.js';
3
3
  export { createPickerFake } from './pickerFake.js';
4
+ export { createTokenExchangeFake } from './tokenExchangeFake.js';
@@ -0,0 +1,67 @@
1
+ /**
2
+ * An in-memory fake of the server-mediated token-exchange endpoint that
3
+ * drive-sync talks to when `DriveSyncOptions.tokenExchangeUrl` is set.
4
+ *
5
+ * The real server accepts `POST {tokenExchangeUrl}` with a body that is
6
+ * EXACTLY one of `{ code }` (new grant) or `{ envelope }` (refresh) and
7
+ * responds `200 { envelope }`. On a refresh it echoes the envelope
8
+ * unchanged while it is still fresh (`Date.now() < expiry_date - 60000`),
9
+ * otherwise it mints a new `payload` + `sig` under the same `guid`.
10
+ *
11
+ * Intended use:
12
+ *
13
+ * ```ts
14
+ * const tokenExchange = createTokenExchangeFake({ now: () => clock })
15
+ * tokenExchange.install()
16
+ * // ... exercise code under test ...
17
+ * expect(tokenExchange.calls[0].kind).toBe('code')
18
+ * tokenExchange.uninstall()
19
+ * ```
20
+ *
21
+ * Only requests whose URL matches the configured endpoint are intercepted;
22
+ * everything else falls through to whatever `globalThis.fetch` was before
23
+ * `install()`.
24
+ */
25
+ import type { Envelope } from '../types.js';
26
+ /** One recorded request against the fake endpoint. */
27
+ export interface TokenExchangeRecordedCall {
28
+ /** `'code'` for a new-grant body, `'envelope'` for a refresh body. */
29
+ kind: 'code' | 'envelope';
30
+ /** The parsed JSON request body, exactly as received. */
31
+ body: unknown;
32
+ }
33
+ export interface TokenExchangeFake {
34
+ /** Swap `globalThis.fetch` for the intercepting handler. Idempotent. */
35
+ install(): void;
36
+ /** Restore the exact `globalThis.fetch` that was present at `install()`. Idempotent. */
37
+ uninstall(): void;
38
+ /** Every intercepted request, in call order. */
39
+ readonly calls: TokenExchangeRecordedCall[];
40
+ /** The most recently minted / echoed envelope, or `null` before the first call. */
41
+ readonly lastEnvelope: Envelope | null;
42
+ /** Make the next `times` (default 1) calls fail `410 refresh_token_revoked`. */
43
+ fail410(times?: number): void;
44
+ /** Make the next `times` (default 1) calls fail `502 google_unavailable`. */
45
+ fail502(times?: number): void;
46
+ /** Make the next `times` (default 1) calls fail `400 malformed_request`. */
47
+ failMalformed(times?: number): void;
48
+ /** Make the next `times` (default 1) calls fail `401 invalid_envelope_signature`. */
49
+ failInvalidSig(times?: number): void;
50
+ /** Make the next `times` (default 1) calls fail `404 unknown_guid`. */
51
+ failUnknownGuid(times?: number): void;
52
+ /**
53
+ * Re-stamp `lastEnvelope`'s expiry to `now() + msFromNow` (and re-sign it),
54
+ * so a subsequent replay of that envelope is treated as stale. Throws if no
55
+ * envelope has been minted yet.
56
+ */
57
+ setExpiry(msFromNow: number): Promise<void>;
58
+ }
59
+ export interface CreateTokenExchangeFakeOptions {
60
+ /** HMAC secret used to sign minted envelopes. Defaults to a fixed test value. */
61
+ secret?: string;
62
+ /** Injectable clock; defaults to `Date.now`. */
63
+ now?: () => number;
64
+ }
65
+ /** Which crypto backend the last signing operation used. Exposed for diagnostics. */
66
+ export declare let lastCryptoBackend: 'crypto.subtle' | 'node:crypto' | null;
67
+ export declare function createTokenExchangeFake(opts?: CreateTokenExchangeFakeOptions): TokenExchangeFake;
@@ -0,0 +1,253 @@
1
+ /**
2
+ * An in-memory fake of the server-mediated token-exchange endpoint that
3
+ * drive-sync talks to when `DriveSyncOptions.tokenExchangeUrl` is set.
4
+ *
5
+ * The real server accepts `POST {tokenExchangeUrl}` with a body that is
6
+ * EXACTLY one of `{ code }` (new grant) or `{ envelope }` (refresh) and
7
+ * responds `200 { envelope }`. On a refresh it echoes the envelope
8
+ * unchanged while it is still fresh (`Date.now() < expiry_date - 60000`),
9
+ * otherwise it mints a new `payload` + `sig` under the same `guid`.
10
+ *
11
+ * Intended use:
12
+ *
13
+ * ```ts
14
+ * const tokenExchange = createTokenExchangeFake({ now: () => clock })
15
+ * tokenExchange.install()
16
+ * // ... exercise code under test ...
17
+ * expect(tokenExchange.calls[0].kind).toBe('code')
18
+ * tokenExchange.uninstall()
19
+ * ```
20
+ *
21
+ * Only requests whose URL matches the configured endpoint are intercepted;
22
+ * everything else falls through to whatever `globalThis.fetch` was before
23
+ * `install()`.
24
+ */
25
+ /** Default endpoint drive-sync ships with. */
26
+ const DEFAULT_TOKEN_EXCHANGE_URL = 'https://open-webapp.duckdns.org/callback';
27
+ /** Scope minted into every fake payload. */
28
+ const FAKE_SCOPE = 'https://www.googleapis.com/auth/drive.file';
29
+ /** Lifetime, in ms, of a freshly minted payload. */
30
+ const TOKEN_LIFETIME_MS = 3_600_000;
31
+ /** Server-side freshness skew: an envelope inside this window is echoed as-is. */
32
+ const REFRESH_SKEW_MS = 60_000;
33
+ /** Canonical JSON: object keys sorted recursively, no insignificant whitespace. */
34
+ function canonicalJson(value) {
35
+ if (value === null || typeof value !== 'object')
36
+ return JSON.stringify(value);
37
+ if (Array.isArray(value))
38
+ return '[' + value.map(canonicalJson).join(',') + ']';
39
+ const entries = Object.keys(value)
40
+ .sort()
41
+ .map((key) => JSON.stringify(key) + ':' + canonicalJson(value[key]));
42
+ return '{' + entries.join(',') + '}';
43
+ }
44
+ function base64UrlFromBytes(bytes) {
45
+ let binary = '';
46
+ for (let i = 0; i < bytes.length; i += 1)
47
+ binary += String.fromCharCode(bytes[i]);
48
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
49
+ }
50
+ /** Which crypto backend the last signing operation used. Exposed for diagnostics. */
51
+ export let lastCryptoBackend = null;
52
+ async function hmacSha256Base64Url(secret, message) {
53
+ const encoder = new TextEncoder();
54
+ const subtle = globalThis.crypto?.subtle;
55
+ if (subtle) {
56
+ lastCryptoBackend = 'crypto.subtle';
57
+ const key = await subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
58
+ 'sign',
59
+ ]);
60
+ const mac = await subtle.sign('HMAC', key, encoder.encode(message));
61
+ return base64UrlFromBytes(new Uint8Array(mac));
62
+ }
63
+ // Fall back to Node's crypto when Web Crypto is unavailable in the test env.
64
+ // Computed specifier keeps TS from trying to resolve `node:crypto` types.
65
+ lastCryptoBackend = 'node:crypto';
66
+ const specifier = 'node:' + 'crypto';
67
+ const nodeCrypto = await import(/* @vite-ignore */ specifier);
68
+ return nodeCrypto.createHmac('sha256', secret).update(message).digest('base64url');
69
+ }
70
+ function randomUuid() {
71
+ const webCrypto = globalThis.crypto;
72
+ if (webCrypto?.randomUUID)
73
+ return webCrypto.randomUUID();
74
+ // Extremely defensive fallback; the test env always has crypto.randomUUID.
75
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
76
+ const r = (Math.random() * 16) | 0;
77
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
78
+ return v.toString(16);
79
+ });
80
+ }
81
+ function randomAccessToken() {
82
+ const webCrypto = globalThis.crypto;
83
+ const bytes = new Uint8Array(24);
84
+ if (webCrypto?.getRandomValues)
85
+ webCrypto.getRandomValues(bytes);
86
+ else
87
+ for (let i = 0; i < bytes.length; i += 1)
88
+ bytes[i] = (Math.random() * 256) | 0;
89
+ let hex = '';
90
+ for (let i = 0; i < bytes.length; i += 1)
91
+ hex += bytes[i].toString(16).padStart(2, '0');
92
+ return 'ya29.' + hex;
93
+ }
94
+ function jsonResponse(status, body) {
95
+ return new Response(JSON.stringify(body), {
96
+ status,
97
+ headers: { 'content-type': 'application/json' },
98
+ });
99
+ }
100
+ function errorResponse(status, code, message) {
101
+ return jsonResponse(status, { error: { code, message } });
102
+ }
103
+ const OVERRIDE_RESPONSES = {
104
+ '410': () => errorResponse(410, 'refresh_token_revoked', 'The refresh token has been revoked'),
105
+ '502': () => errorResponse(502, 'google_unavailable', 'Upstream Google token endpoint is unavailable'),
106
+ malformed: () => errorResponse(400, 'malformed_request', 'The request body was malformed'),
107
+ invalidSig: () => errorResponse(401, 'invalid_envelope_signature', 'The envelope signature did not verify'),
108
+ unknownGuid: () => errorResponse(404, 'unknown_guid', 'No grant exists for that guid'),
109
+ };
110
+ function urlOf(input) {
111
+ if (typeof input === 'string')
112
+ return input;
113
+ if (input instanceof URL)
114
+ return input.href;
115
+ if (typeof Request !== 'undefined' && input instanceof Request)
116
+ return input.url;
117
+ return String(input?.url ?? input);
118
+ }
119
+ async function bodyTextOf(input, init) {
120
+ if (init && typeof init.body === 'string')
121
+ return init.body;
122
+ if (init && init.body != null)
123
+ return String(init.body);
124
+ if (typeof Request !== 'undefined' && input instanceof Request)
125
+ return input.clone().text();
126
+ return '';
127
+ }
128
+ export function createTokenExchangeFake(opts = {}) {
129
+ const secret = opts.secret ?? 'token-exchange-fake-secret';
130
+ const now = opts.now ?? (() => Date.now());
131
+ const calls = [];
132
+ const overrideQueue = [];
133
+ let lastEnvelope = null;
134
+ let installed = false;
135
+ let previousFetch = globalThis.fetch;
136
+ function matchesEndpoint(rawUrl) {
137
+ if (rawUrl === DEFAULT_TOKEN_EXCHANGE_URL)
138
+ return true;
139
+ try {
140
+ return new URL(rawUrl).pathname === '/callback';
141
+ }
142
+ catch {
143
+ return rawUrl.endsWith('/callback');
144
+ }
145
+ }
146
+ async function mintEnvelope(guid) {
147
+ const payload = {
148
+ access_token: randomAccessToken(),
149
+ expiry_date: now() + TOKEN_LIFETIME_MS,
150
+ token_type: 'Bearer',
151
+ scope: FAKE_SCOPE,
152
+ };
153
+ const sig = await hmacSha256Base64Url(secret, canonicalJson({ v: 2, guid, payload }));
154
+ return { v: 2, guid, payload, sig };
155
+ }
156
+ function enqueue(kind, times) {
157
+ const n = Math.max(0, Math.floor(times));
158
+ for (let i = 0; i < n; i += 1)
159
+ overrideQueue.push(kind);
160
+ }
161
+ const handler = (async (input, init) => {
162
+ const rawUrl = urlOf(input);
163
+ if (!matchesEndpoint(rawUrl))
164
+ return previousFetch(input, init);
165
+ let parsed;
166
+ try {
167
+ const text = await bodyTextOf(input, init);
168
+ parsed = text ? JSON.parse(text) : {};
169
+ }
170
+ catch {
171
+ return errorResponse(400, 'malformed_request', 'The request body was not valid JSON');
172
+ }
173
+ const record = parsed !== null && typeof parsed === 'object' ? parsed : {};
174
+ const hasCode = record.code != null;
175
+ const hasEnvelope = record.envelope != null;
176
+ const kind = hasEnvelope && !hasCode ? 'envelope' : 'code';
177
+ calls.push({ kind, body: parsed });
178
+ const override = overrideQueue.shift();
179
+ if (override)
180
+ return OVERRIDE_RESPONSES[override]();
181
+ if (hasCode && hasEnvelope) {
182
+ return errorResponse(400, 'code_and_envelope_exclusive', 'Provide exactly one of `code` or `envelope`');
183
+ }
184
+ if (!hasCode && !hasEnvelope) {
185
+ return errorResponse(400, 'code_or_envelope_required', 'Provide exactly one of `code` or `envelope`');
186
+ }
187
+ if (hasCode) {
188
+ const minted = await mintEnvelope(randomUuid());
189
+ lastEnvelope = minted;
190
+ return jsonResponse(200, { envelope: minted });
191
+ }
192
+ const incoming = record.envelope;
193
+ if (incoming === null ||
194
+ typeof incoming !== 'object' ||
195
+ typeof incoming.payload !== 'object' ||
196
+ incoming.payload === null ||
197
+ typeof incoming.payload.expiry_date !== 'number' ||
198
+ typeof incoming.guid !== 'string') {
199
+ return errorResponse(400, 'malformed_request', 'The `envelope` was not a well-formed envelope');
200
+ }
201
+ if (now() < incoming.payload.expiry_date - REFRESH_SKEW_MS) {
202
+ lastEnvelope = incoming;
203
+ return jsonResponse(200, { envelope: incoming });
204
+ }
205
+ const refreshed = await mintEnvelope(incoming.guid);
206
+ lastEnvelope = refreshed;
207
+ return jsonResponse(200, { envelope: refreshed });
208
+ });
209
+ return {
210
+ install() {
211
+ if (installed)
212
+ return;
213
+ previousFetch = globalThis.fetch;
214
+ globalThis.fetch = handler;
215
+ installed = true;
216
+ },
217
+ uninstall() {
218
+ if (!installed)
219
+ return;
220
+ globalThis.fetch = previousFetch;
221
+ installed = false;
222
+ },
223
+ get calls() {
224
+ return calls;
225
+ },
226
+ get lastEnvelope() {
227
+ return lastEnvelope;
228
+ },
229
+ fail410(times = 1) {
230
+ enqueue('410', times);
231
+ },
232
+ fail502(times = 1) {
233
+ enqueue('502', times);
234
+ },
235
+ failMalformed(times = 1) {
236
+ enqueue('malformed', times);
237
+ },
238
+ failInvalidSig(times = 1) {
239
+ enqueue('invalidSig', times);
240
+ },
241
+ failUnknownGuid(times = 1) {
242
+ enqueue('unknownGuid', times);
243
+ },
244
+ async setExpiry(msFromNow) {
245
+ if (!lastEnvelope) {
246
+ throw new Error('createTokenExchangeFake: setExpiry() called before any envelope was minted');
247
+ }
248
+ const payload = { ...lastEnvelope.payload, expiry_date: now() + msFromNow };
249
+ const sig = await hmacSha256Base64Url(secret, canonicalJson({ v: 2, guid: lastEnvelope.guid, payload }));
250
+ lastEnvelope = { v: 2, guid: lastEnvelope.guid, payload, sig };
251
+ },
252
+ };
253
+ }
package/dist/token.d.ts CHANGED
@@ -31,6 +31,20 @@ export interface AcquireTokenOptions {
31
31
  * turns out to already be usable.
32
32
  */
33
33
  export declare function notifyExternalTokenRefresh(projectId: string): void;
34
+ /**
35
+ * Records that another tab just persisted a fresh envelope for `projectId`.
36
+ * The next {@link import('./envelope.js').refreshEnvelope} call for this
37
+ * project drains the signal and re-reads the stored envelope before deciding
38
+ * whether a network round-trip is needed.
39
+ */
40
+ export declare function notifyExternalEnvelopeRefresh(projectId: string): void;
41
+ /**
42
+ * Consume (one-shot) a pending cross-tab envelope-refresh signal for
43
+ * `projectId`. Returns `true` when a signal was pending (and clears it),
44
+ * `false` otherwise. Mirrors the inline `externallyRefreshed.delete(...)`
45
+ * check the legacy `acquireToken` path performs.
46
+ */
47
+ export declare function consumeExternalEnvelopeRefresh(projectId: string): boolean;
34
48
  /**
35
49
  * Single entry point for acquiring a Drive access token, used by BOTH the
36
50
  * interactive "connect" path (interactive: true -> prompt: '', i.e. no
package/dist/token.js CHANGED
@@ -96,6 +96,34 @@ const externallyRefreshed = new Set();
96
96
  export function notifyExternalTokenRefresh(projectId) {
97
97
  externallyRefreshed.add(projectId);
98
98
  }
99
+ /**
100
+ * Parallel-set mirror of {@link externallyRefreshed} for the server-mediated
101
+ * token-exchange path (envelope.ts `refreshEnvelope`). Kept deliberately
102
+ * separate from the legacy set so the two flows never consume each other's
103
+ * cross-tab signals: a `token` broadcast that originated from an envelope
104
+ * refresh drains this set, and one from a legacy GIS acquisition drains the
105
+ * other. Populated by {@link notifyExternalEnvelopeRefresh}, drained by
106
+ * {@link consumeExternalEnvelopeRefresh}.
107
+ */
108
+ const externallyRefreshedEnvelope = new Set();
109
+ /**
110
+ * Records that another tab just persisted a fresh envelope for `projectId`.
111
+ * The next {@link import('./envelope.js').refreshEnvelope} call for this
112
+ * project drains the signal and re-reads the stored envelope before deciding
113
+ * whether a network round-trip is needed.
114
+ */
115
+ export function notifyExternalEnvelopeRefresh(projectId) {
116
+ externallyRefreshedEnvelope.add(projectId);
117
+ }
118
+ /**
119
+ * Consume (one-shot) a pending cross-tab envelope-refresh signal for
120
+ * `projectId`. Returns `true` when a signal was pending (and clears it),
121
+ * `false` otherwise. Mirrors the inline `externallyRefreshed.delete(...)`
122
+ * check the legacy `acquireToken` path performs.
123
+ */
124
+ export function consumeExternalEnvelopeRefresh(projectId) {
125
+ return externallyRefreshedEnvelope.delete(projectId);
126
+ }
99
127
  /**
100
128
  * Single entry point for acquiring a Drive access token, used by BOTH the
101
129
  * interactive "connect" path (interactive: true -> prompt: '', i.e. no
package/dist/types.d.ts CHANGED
@@ -4,6 +4,34 @@ export interface DriveSyncOptions {
4
4
  clientId: string;
5
5
  folderPath: string[];
6
6
  logger?: Logger;
7
+ /**
8
+ * Opt-in: when set, connect() runs the server-mediated token-exchange flow
9
+ * against this URL instead of the legacy GIS implicit flow. When absent,
10
+ * the legacy implicit flow is used unchanged.
11
+ */
12
+ tokenExchangeUrl?: string;
13
+ }
14
+ /**
15
+ * Decoded contents of an {@link Envelope}'s `payload`. Mirrors the fields of
16
+ * a Google OAuth token response that drive-sync needs.
17
+ */
18
+ export interface EnvelopePayload {
19
+ access_token: string;
20
+ expiry_date: number;
21
+ token_type: 'Bearer';
22
+ scope: string;
23
+ }
24
+ /**
25
+ * Opaque envelope returned by the server-side token-exchange endpoint. The
26
+ * `sig` is a server-side signature that is NEVER verified client-side —
27
+ * drive-sync treats the whole structure as opaque and simply forwards the
28
+ * decoded `payload` into its normal token storage.
29
+ */
30
+ export interface Envelope {
31
+ v: 2;
32
+ guid: string;
33
+ payload: EnvelopePayload;
34
+ sig: string;
7
35
  }
8
36
  /**
9
37
  * Durable connection state returned by getConnection(). Survives token
@@ -31,6 +59,16 @@ export interface FileRef {
31
59
  version?: string;
32
60
  /** Drive's last-modified timestamp (RFC3339), when the call requested it. */
33
61
  modifiedTime?: string;
62
+ /** Drive MIME type, when the call requested it. */
63
+ mimeType?: string;
64
+ /** Short-lived Drive thumbnail URL, when the call requested it and Drive supplied one; may be absent. */
65
+ thumbnailLink?: string;
66
+ /** Pixel dimensions (+ `rotation`, 0/90/180/270) for image files, when Drive supplied them. */
67
+ imageMediaMetadata?: {
68
+ width?: number;
69
+ height?: number;
70
+ rotation?: number;
71
+ };
34
72
  }
35
73
  /**
36
74
  * Sync state of one file relative to what this client last restored.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/drive-sync",
3
- "version": "0.5.7",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",