@open-webapp/drive-sync 0.1.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 +41 -0
- package/SPEC.md +150 -0
- package/dist/broadcast.d.ts +15 -0
- package/dist/broadcast.js +38 -0
- package/dist/connection.d.ts +84 -0
- package/dist/connection.js +90 -0
- package/dist/errors.d.ts +64 -0
- package/dist/errors.js +84 -0
- package/dist/files.d.ts +70 -0
- package/dist/files.js +231 -0
- package/dist/gis.d.ts +8 -0
- package/dist/gis.js +33 -0
- package/dist/http.d.ts +32 -0
- package/dist/http.js +188 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +207 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.js +6 -0
- package/dist/permissions.d.ts +37 -0
- package/dist/permissions.js +71 -0
- package/dist/query.d.ts +7 -0
- package/dist/query.js +9 -0
- package/dist/reconcile.d.ts +12 -0
- package/dist/reconcile.js +45 -0
- package/dist/refresh.d.ts +34 -0
- package/dist/refresh.js +89 -0
- package/dist/storage.d.ts +27 -0
- package/dist/storage.js +70 -0
- package/dist/testing/driveFake.d.ts +50 -0
- package/dist/testing/driveFake.js +423 -0
- package/dist/testing/gisFake.d.ts +60 -0
- package/dist/testing/gisFake.js +86 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +2 -0
- package/dist/token.d.ts +54 -0
- package/dist/token.js +140 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +1 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @open-webapp/drive-sync
|
|
2
|
+
|
|
3
|
+
Plain-TypeScript, React-free library for Google Drive OAuth token lifecycle,
|
|
4
|
+
storage, and low-level Drive file/permission operations. Extracted from
|
|
5
|
+
`open-webapp/planning` and `notesdiary/app`, which each had their own fork of
|
|
6
|
+
this logic (and the same 11 bugs).
|
|
7
|
+
|
|
8
|
+
The library owns auth + storage + Drive I/O. Merge logic, file naming, and
|
|
9
|
+
content format stay app-side.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createDriveSync } from '@open-webapp/drive-sync'
|
|
15
|
+
|
|
16
|
+
const drive = createDriveSync({
|
|
17
|
+
appId: 'my-app',
|
|
18
|
+
clientId: 'xxx.apps.googleusercontent.com',
|
|
19
|
+
folderPath: ['MyApp', 'Data'],
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const dispose = drive.activate()
|
|
23
|
+
await drive.reconcile(knownProjectIds)
|
|
24
|
+
|
|
25
|
+
const p = drive.project(projectId)
|
|
26
|
+
await p.connect()
|
|
27
|
+
const folderId = await p.ensureFolderPath()
|
|
28
|
+
await p.files.write({ folderId, name: 'data.json', content: '{}', mimeType: 'application/json' })
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
See `SPEC.md` for the full design: the 34 resolved decisions, storage layout,
|
|
32
|
+
and refresh state machine. `SPEC.md` is descriptive, written from the shipped
|
|
33
|
+
code — if it ever disagrees with the source, the source wins.
|
|
34
|
+
|
|
35
|
+
## Testing
|
|
36
|
+
|
|
37
|
+
Import fakes for GIS and Drive from the `./testing` subpath:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createGisFake, createDriveFake } from '@open-webapp/drive-sync/testing'
|
|
41
|
+
```
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# `@open-webapp/drive-sync` — Spec
|
|
2
|
+
|
|
3
|
+
**Status: descriptive, not normative.** Written last, from the shipped code in `src/`. If anything here disagrees with the source, the source is right and this file should be corrected.
|
|
4
|
+
|
|
5
|
+
## 1. Overview
|
|
6
|
+
|
|
7
|
+
`drive-sync` is a plain-TypeScript (no React, one runtime dependency — `idb`), browser-only library that owns two things for an app that backs project data onto a user's Google Drive:
|
|
8
|
+
|
|
9
|
+
- **OAuth token lifecycle**: acquiring, caching, silently refreshing, and revoking a Google Identity Services (GIS) access token, per project.
|
|
10
|
+
- **Low-level Drive I/O**: file read/write/list/remove, folder-path resolution, and Drive permissions — all as thin, content-agnostic wrappers around the Drive v3 REST API.
|
|
11
|
+
|
|
12
|
+
It deliberately does **not** implement any merge/diff logic, file-naming convention, or "sync" abstraction. There is no `sync()` call anywhere in the package. An app decides what a "project" contains, what its files are named, how conflicting versions get merged, and when to call `read`/`write` — the library only gets it there and back, authenticated, retried, and typed.
|
|
13
|
+
|
|
14
|
+
### Public API
|
|
15
|
+
|
|
16
|
+
`src/index.ts` exports one factory:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { createDriveSync } from '@open-webapp/drive-sync';
|
|
20
|
+
|
|
21
|
+
const drive = createDriveSync({
|
|
22
|
+
appId: 'planning',
|
|
23
|
+
clientId: GOOGLE_CLIENT_ID,
|
|
24
|
+
folderPath: ['OpenWebApp', 'Planning'],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const dispose = drive.activate(); // attach visibility/pageshow listeners
|
|
28
|
+
await drive.reconcile(knownProjectIds); // drop orphaned per-project auth DBs
|
|
29
|
+
|
|
30
|
+
const p = drive.project(projectId);
|
|
31
|
+
await p.connect(); // interactive; prompt:'consent'
|
|
32
|
+
const conn = await p.getConnection(); // { email, needsReauth, expiresAt } | null
|
|
33
|
+
|
|
34
|
+
const folderId = await p.ensureFolderPath();
|
|
35
|
+
const files = await p.files.list({ folderId });
|
|
36
|
+
const text = await p.files.read(fileId); // string | Blob | null (null on 404)
|
|
37
|
+
const ref = await p.files.write({ folderId, name: 'x.json', content, mimeType: 'application/json' });
|
|
38
|
+
await p.permissions.grant({ fileId, type: 'user', role: 'writer', emailAddress: 'a@b.com' });
|
|
39
|
+
|
|
40
|
+
await p.disconnect();
|
|
41
|
+
await drive.dropProject(projectId);
|
|
42
|
+
dispose();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`createDriveSync()` itself attaches no listeners and makes no network calls. Every Drive-op call site accepts an optional `{ interactive?: boolean }` (default `false`) and resolves its own token internally — no caller ever threads a token or a `projectId` string into an HTTP call by hand.
|
|
46
|
+
|
|
47
|
+
Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHandle`/`PermissionsHandle`), `connection.ts` (`connect`/`getConnection`/`disconnect`/`refreshSilently`), `files.ts`, `permissions.ts`, `reconcile.ts`, `refresh.ts` (`activate`/warm-up), `errors.ts` (typed error classes), `types.ts` (`DriveSyncOptions`, `Connection`, `StoredToken`, `FileRef`, `DrivePermission`, `CallOptions`).
|
|
48
|
+
|
|
49
|
+
## 2. The 34 resolved design decisions
|
|
50
|
+
|
|
51
|
+
**Bugs fixed (both source apps carried these):**
|
|
52
|
+
|
|
53
|
+
1. **Per-request token client, not a module singleton** — `token.ts`'s `acquireToken`/`acquireTokenUncoalesced` creates a fresh `initTokenClient` on every call; nothing closes over the first call's `projectId`.
|
|
54
|
+
2. **Scope honored on every call** — the fresh client is configured with `opts.scopes.join(' ')` per call, not baked in once at init.
|
|
55
|
+
3. **In-flight coalescing keyed by `(projectId, sorted scopes)`** — `token.ts`'s `coalesceKey` + `inFlight` map; concurrent calls for different projects/scopes never collide.
|
|
56
|
+
4. **No clobbered resolvers** — `resolve`/`reject` are captured in each call's own `Promise` closure (`acquireTokenUncoalesced`), never stored on a module-level variable.
|
|
57
|
+
5. **Real expiry** — `persistTokenResponse` reads `response.expires_in` and computes `Date.now() + expiresIn * 1000`; no hardcoded `3600`.
|
|
58
|
+
6. **`grantedScopes` recorded** — `persistTokenResponse` splits `response.scope` and stores it on the token; `connection.ts`'s `connect()` also copies it onto the durable `ConnRecord`.
|
|
59
|
+
7. **401 handled** — `http.ts`'s `performFetch` clears the token, retries once non-interactively, then throws `NeedsReauthError` (see §4).
|
|
60
|
+
8. **`hint` on silent refresh** — every non-interactive `acquireToken` call is given `hint: <known email>`; wrong-account tokens are caught by `refreshSilently` (see below and §4).
|
|
61
|
+
9. **`response.ok` checked before parsing** — `performFetch` never calls `.json()`/`.text()` on a response without checking `res.ok` first; every status branch is explicit.
|
|
62
|
+
10. **429/5xx retry** — `performFetch`'s attempt loop, up to `MAX_ATTEMPTS = 3`, honoring `Retry-After`.
|
|
63
|
+
11. **No hand-rolled multipart boundary** — `files.ts`'s `write()` (create path) builds a real `FormData`, serializes it via a throwaway `Request` to get fetch's own computed boundary/Content-Type, and forwards that verbatim.
|
|
64
|
+
|
|
65
|
+
**Fixes adopted from whichever app had them right:**
|
|
66
|
+
|
|
67
|
+
12. **GIS load guard** — `gis.ts`'s `waitForGoogleIdentityServices`: 100ms poll, 10s timeout, typed `GisLoadError`.
|
|
68
|
+
13. **`ACCESS_TOKEN_SCOPE_INSUFFICIENT` handling** — `http.ts` checks the 403 body for that string and throws `ScopeInsufficientError`, clearing the token first.
|
|
69
|
+
14. **`q=` escaping** — `query.ts`'s `escapeQ`: backslash escaped before quote (both apps had this wrong or partial; this is neither app's code, written fresh to the correct rule).
|
|
70
|
+
15. **Structured errors, not string parsing** — `errors.ts`'s `DriveSyncError` subclasses carry `status`/`reason`/`retryAfter`/`fileId`/`expectedEmail`/`actualEmail` fields.
|
|
71
|
+
16. **`disconnect()` early-returns the revoke POST** when no token is cached — `connection.ts`'s `disconnect()` checks `getToken()` before calling the injected `revokeFn`.
|
|
72
|
+
|
|
73
|
+
**Other resolved decisions:**
|
|
74
|
+
|
|
75
|
+
17. **`prompt` selection** — `token.ts`: `interactive ? 'consent' : 'none'`, with `hint` only ever attached on the non-interactive path.
|
|
76
|
+
18. **Fully async surface** — no synchronous accessors anywhere in `index.ts`/`connection.ts`/`storage.ts`.
|
|
77
|
+
19. **One connection object, not two** — `getConnection()` (`connection.ts`) returns `{ email, needsReauth, expiresAt } | null` rather than a separate `{authenticated, cachedToken}` shape.
|
|
78
|
+
20. **Injectable no-op logger** — `logger.ts`'s `Logger` interface + `noOpLogger`, taken as `options.logger` in `createDriveSync`.
|
|
79
|
+
21. **`FormData` multipart create** — see #11 above; implemented in `files.ts`.
|
|
80
|
+
22. **Content-agnostic payload** — `WriteOptions.content: string | Blob` plus an explicit `mimeType` (`files.ts`).
|
|
81
|
+
23. **`folderPath` supplied at factory time** — `DriveSyncOptions.folderPath: string[]`; `ensureFolderPath()` walks it (`files.ts`).
|
|
82
|
+
24. **Retry policy** — bounded exponential backoff (`BASE_DELAY_MS * 2^(attempt-1)`), 3 attempts, `Retry-After` honored when present, and **no retry on any non-429 4xx** (`http.ts`).
|
|
83
|
+
25–27. **App-side concerns kept out of the library** — `ensureJsonExtension`, CSV filename/content building, and any app-level `connectDriveSync`-style helper are not present anywhere in `src/`; the library only exposes `ensureFolderPath()` + `files.write()` for an app to build such helpers on top of.
|
|
84
|
+
28. **No `folderId`/`fileId` persistence** — `ensureFolderPath()` and `write()` both return ids to the caller; nothing in `storage.ts`'s schema has a field for either.
|
|
85
|
+
29. **One IndexedDB DB per project** — `storage.ts`'s `dbName(appId, projectId)` → `owa-drive-{appId}-{projectId}`, opened at version `1` with a single object store named `auth`.
|
|
86
|
+
30. **`conn`/`token` split** — `storage.ts` stores a durable `ConnRecord` under key `'conn'` and an ephemeral `StoredToken` under key `'token'` in the same `auth` store; `clearToken` deletes only the `'token'` key.
|
|
87
|
+
31. **Cross-tab BroadcastChannel — partially wired.** `broadcast.ts` implements `createBroadcast(appId)` with `postLogout`, `postToken`, and `onMessage`, channel-named `owa-drive-{appId}`, feature-detected to a no-op where `BroadcastChannel` is absent. In the shipped code, only `postLogout` is actually called (from `connection.ts`'s `disconnect()`). **Deviation from the plan:** nothing in `src/` calls `onMessage` or `postToken` — there is no listener that reacts to a `logout` broadcast in another tab, and no token-sharing path exists. The plumbing for both exists and is exported/testable, but "logout propagation" and "token sharing" as end-to-end behaviors are not wired up by this package itself; a consuming app would need to call `createBroadcast`/`onMessage` itself if it wants that (and `createBroadcast` is not currently exported from `index.ts`).
|
|
88
|
+
32. **`reconcile`/`dropProject`** — `reconcile.ts`: `reconcile(appId, knownProjectIds)` enumerates via `indexedDB.databases()` and deletes any `owa-drive-{appId}-*` DB not in the known set; `dropProject(appId, projectId)` deletes one DB eagerly and evicts its cached handle.
|
|
89
|
+
33. **No timer; warm-up on `visibilitychange`/`pageshow`** — `refresh.ts`'s `activate()` attaches both listeners (only when called; none at import time), gated on `document.visibilityState === 'visible'` / `event.persisted && !document.hidden`; `warmUpIfNeeded` only fires if a connection exists **and** the token is missing or within a 5-minute buffer (`REFRESH_BUFFER_MS`) of expiry. `index.ts`'s top-level `activate()` also layers a `trackedProjectIds` Set so one global listener pair drives warm-ups for every project ever passed to `.project(id)`.
|
|
90
|
+
34. **`interactive` option, default `false`** — every `BaseCallOptions`-shaped call in `files.ts`/`permissions.ts`/`http.ts` defaults `interactive` to falsy; a non-interactive call with no usable token throws `NeedsReauthError` rather than silently prompting.
|
|
91
|
+
|
|
92
|
+
## 3. Storage layout
|
|
93
|
+
|
|
94
|
+
Each project gets its own IndexedDB database: **`owa-drive-{appId}-{projectId}`**, version 1, containing one object store, `auth` (`storage.ts`). The store holds exactly two keys:
|
|
95
|
+
|
|
96
|
+
| Key | Shape | Lifetime |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `conn` | `{ email, grantedScopes: string[], connectedAt: number }` | Durable — survives token expiry. Written by `connect()`. Cleared only by `disconnect()`. |
|
|
99
|
+
| `token` | `{ accessToken, expiresAt, grantedScopes: string[] }` | Ephemeral. Written by `persistTokenResponse()` on every successful token acquisition. Cleared on 401 (`http.ts`), on `ScopeInsufficientError` (`http.ts`), on a detected wrong-account mismatch (`connection.ts`'s `refreshSilently`), and by `disconnect()`. |
|
|
100
|
+
|
|
101
|
+
Open handles are cached in-process in a `Map<string, Promise<IDBPDatabase>>` keyed by `${appId}:${projectId}` (`storage.ts`'s `dbCache`), so repeated calls for the same project reuse one connection. `evictDbHandle` closes and drops that cache entry without deleting the underlying database — the deletion itself only happens in `reconcile.ts`.
|
|
102
|
+
|
|
103
|
+
Two things trigger deleting the whole per-project database:
|
|
104
|
+
|
|
105
|
+
- **`drive.dropProject(projectId)`** — the eager, app-driven path (e.g. called when a project is deleted in the host app).
|
|
106
|
+
- **`drive.reconcile(knownProjectIds)`** — the safety net, run at boot: enumerates every `owa-drive-{appId}-*` database via `indexedDB.databases()` and deletes any whose trailing projectId is not in the supplied set. No-ops (does not throw) where `indexedDB.databases()` is unsupported.
|
|
107
|
+
|
|
108
|
+
Nothing in this schema stores a Drive `folderId` or `fileId` — those stay app-side by design (#28 above).
|
|
109
|
+
|
|
110
|
+
## 4. Refresh state machine
|
|
111
|
+
|
|
112
|
+
Token acquisition always funnels through `token.ts`'s `acquireToken`, which is coalesced per `(projectId, sorted scopes)` and never keeps module-level mutable state across calls. Four distinct callers drive it, each representing a different "state":
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
[No connection]
|
|
116
|
+
| connect() (connection.ts)
|
|
117
|
+
| acquireToken({interactive:true}) -> prompt:'consent', no hint
|
|
118
|
+
v
|
|
119
|
+
[Connected, token cached] <---------------------------------------------+
|
|
120
|
+
| |
|
|
121
|
+
| token missing/expired | success
|
|
122
|
+
v |
|
|
123
|
+
[Silent refresh attempt] -- acquireToken({interactive:false, hint:email})+
|
|
124
|
+
| triggered by 3 independent call sites:
|
|
125
|
+
| (a) http.ts 401 handler -> refreshSilently, retry original request ONCE
|
|
126
|
+
| (b) refresh.ts warmUpIfNeeded -> refreshSilently, proactive, background
|
|
127
|
+
| (c) refresh.ts (no fetchEmail) -> plain acquireToken fallback, background only
|
|
128
|
+
|
|
|
129
|
+
+-- GIS error / no token -----------------> NeedsReauthError
|
|
130
|
+
+-- GIS returns token for the RIGHT email -> [Connected, token cached]
|
|
131
|
+
+-- GIS returns token for the WRONG email -> clearToken(); WrongAccountError
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Concretely, by module:
|
|
135
|
+
|
|
136
|
+
- **`token.ts`** is the only place that talks to GIS's `initTokenClient`. It does not know about "wrong account" — it just returns whatever token GIS hands back for the requested `(scopes, prompt, hint)`.
|
|
137
|
+
- **`connection.ts`**'s `refreshSilently` is the *only* place that adds wrong-account verification: after `acquireToken({interactive:false, hint:expectedEmail})` resolves, it calls the injected `fetchEmail(token.accessToken)` and compares the result against `expectedEmail`. Mismatch → `clearToken()` then throw `WrongAccountError`; match → return the token.
|
|
138
|
+
- **`http.ts`**'s `driveFetch`/`performFetch` is the 401 path: on a first 401 (not already a retry, not an interactive call), it clears the token and, if a `fetchEmail` was supplied and a connection's email is known, calls `refreshSilently`; otherwise falls back to a bare `acquireToken`. It retries the original request exactly once (`isRetryAfter401` flag) with whatever token comes back. A second 401, or any 401 on an interactive call, throws `NeedsReauthError` without retrying again. A `WrongAccountError` from `refreshSilently` is re-thrown as-is rather than being swallowed into `NeedsReauthError`.
|
|
139
|
+
- **`refresh.ts`**'s `warmUpIfNeeded` is the proactive path: fired from `visibilitychange`→`visible` and `pageshow`(persisted, not hidden) listeners attached by `activate()`. It only acts if a `conn` record exists **and** the cached token is missing or within `REFRESH_BUFFER_MS` (5 minutes) of `expiresAt`. When a `fetchEmail` is configured it goes through `refreshSilently` (so wrong-account detection also covers this path); otherwise it falls back to a bare `acquireToken`. It never *starts* a new attempt while the document is hidden — visibility is checked before it is ever called, so an attempt already in flight from before the tab hid is left to finish on its own.
|
|
140
|
+
- **`index.ts`**'s top-level `activate()` layers one global listener pair over `refresh.ts`'s per-call logic: it tracks every `projectId` ever passed to `.project(id)` in a `Set` and, on each visibility/pageshow event, calls `warmUpIfNeeded` for all of them (read live at fire time, so late-registered projects are still covered).
|
|
141
|
+
|
|
142
|
+
Wrong-account detection therefore covers exactly two silent paths — the 401-retry-once in `http.ts` and the proactive warm-up in `refresh.ts`/`index.ts` — both of which are wired through `refreshSilently`. It does **not** cover the interactive `connect()` path (a user consenting is trusted at face value) nor any refresh path where the caller omitted `fetchEmail` (the `refresh.ts` fallback branch and any hand-rolled use of `acquireToken` directly).
|
|
143
|
+
|
|
144
|
+
## 5. Known limitations / accepted tradeoffs
|
|
145
|
+
|
|
146
|
+
- **`reconcile()` degrades to a no-op** where `indexedDB.databases()` is unsupported (Firefox, older Safari at time of writing). On those browsers, orphaned per-project auth databases from deleted projects are never automatically reclaimed unless the app calls `dropProject(id)` eagerly when it deletes the project — `reconcile()` is a safety net, not the primary cleanup mechanism.
|
|
147
|
+
- **`files.ts`'s `read()` returns `null` on 404**, and that single value conflates two different situations: a genuinely wrong/nonexistent `fileId`, and a file that exists but that the currently-authenticated account cannot see (e.g. connected as the wrong Google account). The library cannot distinguish these — Drive itself returns an identical 404 for both — so callers that want to give an honest error message need to account for both cases themselves.
|
|
148
|
+
- **Wrong-account detection is not universal.** As detailed in §4, it is implemented once, inside `connection.ts`'s `refreshSilently`, and is only reached via two call sites: the 401-triggered silent refresh in `http.ts`, and the proactive warm-up in `refresh.ts` (when a `fetchEmail` resolver is supplied — `index.ts` always supplies one). It is **not** checked on the interactive `connect()` path, and the `refresh.ts` fallback branch that calls `acquireToken` directly (used only when no `fetchEmail` is configured) bypasses it entirely. A non-401 Drive call that succeeds against a token silently swapped to the wrong account (rather than expiring first) would not be caught until some later 401 or explicit `getConnection()`/email check.
|
|
149
|
+
- **Cross-tab `BroadcastChannel` support (`broadcast.ts`) is only half-used.** `postLogout` fires on `disconnect()`, but nothing in the package subscribes via `onMessage`, and `postToken` is never called — see decision #31 above. Multi-tab logout propagation and token sharing are not actual end-to-end behaviors of this package as shipped, only exposed building blocks.
|
|
150
|
+
- **`ensureFolderPath()`'s root-level lookup has no anchor.** Because the library only holds the `drive.file` scope, the first path segment is searched for by name/mimeType with no `in parents` constraint (every subsequent level is unambiguous, anchored to the previous level's id). Two folders with the same name at the top level anywhere the app can see are indistinguishable to this lookup; the first match wins.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface BroadcastMessage {
|
|
2
|
+
type: 'logout' | 'token';
|
|
3
|
+
projectId: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Broadcast {
|
|
6
|
+
postLogout(projectId: string): void;
|
|
7
|
+
postToken(projectId: string): void;
|
|
8
|
+
onMessage(handler: (msg: BroadcastMessage) => void): () => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Creates a cross-tab broadcast channel scoped to a single appId. Feature
|
|
12
|
+
* detects BroadcastChannel and no-ops gracefully in environments where it is
|
|
13
|
+
* unavailable (e.g. some server-side or older browser contexts).
|
|
14
|
+
*/
|
|
15
|
+
export declare function createBroadcast(appId: string): Broadcast;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a cross-tab broadcast channel scoped to a single appId. Feature
|
|
3
|
+
* detects BroadcastChannel and no-ops gracefully in environments where it is
|
|
4
|
+
* unavailable (e.g. some server-side or older browser contexts).
|
|
5
|
+
*/
|
|
6
|
+
export function createBroadcast(appId) {
|
|
7
|
+
if (typeof BroadcastChannel === 'undefined') {
|
|
8
|
+
return {
|
|
9
|
+
postLogout() { },
|
|
10
|
+
postToken() { },
|
|
11
|
+
onMessage() {
|
|
12
|
+
return () => { };
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const channel = new BroadcastChannel(`owa-drive-${appId}`);
|
|
17
|
+
function post(type, projectId) {
|
|
18
|
+
const msg = { type, projectId };
|
|
19
|
+
channel.postMessage(msg);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
postLogout(projectId) {
|
|
23
|
+
post('logout', projectId);
|
|
24
|
+
},
|
|
25
|
+
postToken(projectId) {
|
|
26
|
+
post('token', projectId);
|
|
27
|
+
},
|
|
28
|
+
onMessage(handler) {
|
|
29
|
+
const listener = (event) => {
|
|
30
|
+
handler(event.data);
|
|
31
|
+
};
|
|
32
|
+
channel.addEventListener('message', listener);
|
|
33
|
+
return () => {
|
|
34
|
+
channel.removeEventListener('message', listener);
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Logger } from './logger.js';
|
|
2
|
+
import type { Connection, StoredToken } from './types.js';
|
|
3
|
+
export interface ConnectOptions {
|
|
4
|
+
appId: string;
|
|
5
|
+
projectId: string;
|
|
6
|
+
clientId: string;
|
|
7
|
+
scopes: string[];
|
|
8
|
+
logger?: Logger;
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the connected account's email from a fresh access token.
|
|
11
|
+
* Injected rather than implemented here because drive-sync does not yet
|
|
12
|
+
* have an http.ts (a later task); index.ts will wire this to a real fetch
|
|
13
|
+
* against the Google userinfo endpoint once http.ts exists.
|
|
14
|
+
*/
|
|
15
|
+
fetchEmail: (accessToken: string) => Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Interactive connection flow: acquires a token with prompt: 'consent',
|
|
19
|
+
* resolves the account email, and persists the durable Connection record.
|
|
20
|
+
*/
|
|
21
|
+
export declare function connect(opts: ConnectOptions): Promise<Connection>;
|
|
22
|
+
export interface RefreshSilentlyOptions {
|
|
23
|
+
appId: string;
|
|
24
|
+
projectId: string;
|
|
25
|
+
clientId: string;
|
|
26
|
+
scopes: string[];
|
|
27
|
+
/**
|
|
28
|
+
* The email the resulting token MUST belong to (i.e. the currently stored
|
|
29
|
+
* connection's email). GIS's `hint` is only ever a hint to Google — under
|
|
30
|
+
* some multi-login browser states it can silently hand back a valid token
|
|
31
|
+
* for a DIFFERENT account than the one hinted. This function is the single
|
|
32
|
+
* place that closes that gap for every non-interactive (silent) refresh of
|
|
33
|
+
* an EXISTING connection.
|
|
34
|
+
*/
|
|
35
|
+
expectedEmail: string;
|
|
36
|
+
/** Resolves the account email from a fresh access token (same shape as
|
|
37
|
+
* ConnectOptions.fetchEmail — deliberately injected rather than
|
|
38
|
+
* implemented here, so this module still has no direct network
|
|
39
|
+
* dependency). */
|
|
40
|
+
fetchEmail: (accessToken: string) => Promise<string>;
|
|
41
|
+
logger?: Logger;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Wraps a non-interactive `acquireToken` call with account-identity
|
|
45
|
+
* verification: after GIS hands back a token, resolves the email it
|
|
46
|
+
* actually belongs to and compares it against `expectedEmail`. On mismatch,
|
|
47
|
+
* clears the now-suspect cached token (so a caller retrying does not reuse
|
|
48
|
+
* it) and throws `WrongAccountError` instead of returning the token.
|
|
49
|
+
*
|
|
50
|
+
* This is the ONLY place non-interactive refreshes for an existing
|
|
51
|
+
* connection should go through — http.ts's 401-retry path and refresh.ts's
|
|
52
|
+
* proactive warm-up both call this rather than `acquireToken` directly.
|
|
53
|
+
*/
|
|
54
|
+
export declare function refreshSilently(opts: RefreshSilentlyOptions): Promise<StoredToken>;
|
|
55
|
+
export interface GetConnectionOptions {
|
|
56
|
+
appId: string;
|
|
57
|
+
projectId: string;
|
|
58
|
+
requiredScopes: string[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Reads the durable connection + cached token from storage with NO network
|
|
62
|
+
* calls. needsReauth is computed purely from scope coverage: true if the
|
|
63
|
+
* granted scopes on the stored connection are missing any required scope.
|
|
64
|
+
*/
|
|
65
|
+
export declare function getConnection(opts: GetConnectionOptions): Promise<Connection | null>;
|
|
66
|
+
export interface DisconnectOptions {
|
|
67
|
+
appId: string;
|
|
68
|
+
projectId: string;
|
|
69
|
+
/**
|
|
70
|
+
* Revokes the cached access token upstream. Injected because drive-sync
|
|
71
|
+
* does not yet have an http.ts; index.ts will wire this to a real fetch
|
|
72
|
+
* against the Google token revocation endpoint once http.ts exists. Only
|
|
73
|
+
* called when a token is actually cached — mirrors a fixed bug where the
|
|
74
|
+
* old code always POSTed a revoke request even when there was nothing to
|
|
75
|
+
* revoke.
|
|
76
|
+
*/
|
|
77
|
+
revokeFn?: (accessToken: string) => Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Disconnects a project: revokes the cached token (if any and if a
|
|
81
|
+
* revokeFn was supplied), then unconditionally clears both the durable
|
|
82
|
+
* connection and the cached token, and broadcasts a logout to other tabs.
|
|
83
|
+
*/
|
|
84
|
+
export declare function disconnect(opts: DisconnectOptions): Promise<void>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { getConn, setConn, clearConn, getToken, clearToken } from './storage.js';
|
|
2
|
+
import { createBroadcast } from './broadcast.js';
|
|
3
|
+
import { acquireToken } from './token.js';
|
|
4
|
+
import { WrongAccountError } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Interactive connection flow: acquires a token with prompt: 'consent',
|
|
7
|
+
* resolves the account email, and persists the durable Connection record.
|
|
8
|
+
*/
|
|
9
|
+
export async function connect(opts) {
|
|
10
|
+
const token = await acquireToken({
|
|
11
|
+
appId: opts.appId,
|
|
12
|
+
projectId: opts.projectId,
|
|
13
|
+
clientId: opts.clientId,
|
|
14
|
+
scopes: opts.scopes,
|
|
15
|
+
interactive: true,
|
|
16
|
+
logger: opts.logger,
|
|
17
|
+
});
|
|
18
|
+
const email = await opts.fetchEmail(token.accessToken);
|
|
19
|
+
await setConn(opts.appId, opts.projectId, {
|
|
20
|
+
email,
|
|
21
|
+
grantedScopes: token.grantedScopes,
|
|
22
|
+
connectedAt: Date.now(),
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
email,
|
|
26
|
+
needsReauth: false,
|
|
27
|
+
expiresAt: token.expiresAt,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Wraps a non-interactive `acquireToken` call with account-identity
|
|
32
|
+
* verification: after GIS hands back a token, resolves the email it
|
|
33
|
+
* actually belongs to and compares it against `expectedEmail`. On mismatch,
|
|
34
|
+
* clears the now-suspect cached token (so a caller retrying does not reuse
|
|
35
|
+
* it) and throws `WrongAccountError` instead of returning the token.
|
|
36
|
+
*
|
|
37
|
+
* This is the ONLY place non-interactive refreshes for an existing
|
|
38
|
+
* connection should go through — http.ts's 401-retry path and refresh.ts's
|
|
39
|
+
* proactive warm-up both call this rather than `acquireToken` directly.
|
|
40
|
+
*/
|
|
41
|
+
export async function refreshSilently(opts) {
|
|
42
|
+
const token = await acquireToken({
|
|
43
|
+
appId: opts.appId,
|
|
44
|
+
projectId: opts.projectId,
|
|
45
|
+
clientId: opts.clientId,
|
|
46
|
+
scopes: opts.scopes,
|
|
47
|
+
interactive: false,
|
|
48
|
+
hint: opts.expectedEmail,
|
|
49
|
+
logger: opts.logger,
|
|
50
|
+
});
|
|
51
|
+
const actualEmail = await opts.fetchEmail(token.accessToken);
|
|
52
|
+
if (actualEmail !== opts.expectedEmail) {
|
|
53
|
+
await clearToken(opts.appId, opts.projectId);
|
|
54
|
+
throw new WrongAccountError({ expectedEmail: opts.expectedEmail, actualEmail });
|
|
55
|
+
}
|
|
56
|
+
return token;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Reads the durable connection + cached token from storage with NO network
|
|
60
|
+
* calls. needsReauth is computed purely from scope coverage: true if the
|
|
61
|
+
* granted scopes on the stored connection are missing any required scope.
|
|
62
|
+
*/
|
|
63
|
+
export async function getConnection(opts) {
|
|
64
|
+
const conn = await getConn(opts.appId, opts.projectId);
|
|
65
|
+
if (!conn) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const token = await getToken(opts.appId, opts.projectId);
|
|
69
|
+
const grantedScopes = new Set(conn.grantedScopes);
|
|
70
|
+
const needsReauth = opts.requiredScopes.some((scope) => !grantedScopes.has(scope));
|
|
71
|
+
return {
|
|
72
|
+
email: conn.email,
|
|
73
|
+
needsReauth,
|
|
74
|
+
expiresAt: token?.expiresAt ?? null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Disconnects a project: revokes the cached token (if any and if a
|
|
79
|
+
* revokeFn was supplied), then unconditionally clears both the durable
|
|
80
|
+
* connection and the cached token, and broadcasts a logout to other tabs.
|
|
81
|
+
*/
|
|
82
|
+
export async function disconnect(opts) {
|
|
83
|
+
const token = await getToken(opts.appId, opts.projectId);
|
|
84
|
+
if (token && opts.revokeFn) {
|
|
85
|
+
await opts.revokeFn(token.accessToken);
|
|
86
|
+
}
|
|
87
|
+
await clearConn(opts.appId, opts.projectId);
|
|
88
|
+
await clearToken(opts.appId, opts.projectId);
|
|
89
|
+
createBroadcast(opts.appId).postLogout(opts.projectId);
|
|
90
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error classes for drive-sync. All carry structured fields (status,
|
|
3
|
+
* reason, email, fileId, retryAfter, etc.) rather than baking the only
|
|
4
|
+
* available information into a formatted message string, so callers can
|
|
5
|
+
* branch on error shape without string-parsing.
|
|
6
|
+
*/
|
|
7
|
+
export interface DriveSyncErrorOptions {
|
|
8
|
+
status?: number;
|
|
9
|
+
reason?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class DriveSyncError extends Error {
|
|
12
|
+
status?: number;
|
|
13
|
+
reason?: string;
|
|
14
|
+
constructor(message: string, opts?: DriveSyncErrorOptions);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Thrown when no usable token exists and the requested call is
|
|
18
|
+
* non-interactive (so no popup/redirect flow may be triggered to obtain one).
|
|
19
|
+
*/
|
|
20
|
+
export declare class NeedsReauthError extends DriveSyncError {
|
|
21
|
+
constructor(message?: string, opts?: DriveSyncErrorOptions);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Thrown on a 403 response with reason ACCESS_TOKEN_SCOPE_INSUFFICIENT.
|
|
25
|
+
*/
|
|
26
|
+
export declare class ScopeInsufficientError extends DriveSyncError {
|
|
27
|
+
constructor(message?: string, opts?: DriveSyncErrorOptions);
|
|
28
|
+
}
|
|
29
|
+
export interface WrongAccountErrorOptions extends DriveSyncErrorOptions {
|
|
30
|
+
expectedEmail: string;
|
|
31
|
+
actualEmail?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Thrown when the authenticated account does not match the expected one
|
|
35
|
+
* (e.g. a silent refresh returned a token for a different Google account).
|
|
36
|
+
*/
|
|
37
|
+
export declare class WrongAccountError extends DriveSyncError {
|
|
38
|
+
expectedEmail: string;
|
|
39
|
+
actualEmail?: string;
|
|
40
|
+
/** Convenience alias mirroring the spec's `this.email` field (== actualEmail). */
|
|
41
|
+
email?: string;
|
|
42
|
+
constructor(opts: WrongAccountErrorOptions);
|
|
43
|
+
}
|
|
44
|
+
/** Thrown when a Drive file lookup returns 404. */
|
|
45
|
+
export declare class NotFoundError extends DriveSyncError {
|
|
46
|
+
fileId: string;
|
|
47
|
+
constructor(fileId: string, opts?: DriveSyncErrorOptions);
|
|
48
|
+
}
|
|
49
|
+
export interface RateLimitedErrorOptions extends DriveSyncErrorOptions {
|
|
50
|
+
retryAfter?: number;
|
|
51
|
+
}
|
|
52
|
+
/** Thrown on a 429 response. */
|
|
53
|
+
export declare class RateLimitedError extends DriveSyncError {
|
|
54
|
+
retryAfter?: number;
|
|
55
|
+
constructor(opts?: RateLimitedErrorOptions);
|
|
56
|
+
}
|
|
57
|
+
/** Generic 5xx-after-retries failure. */
|
|
58
|
+
export declare class TransientError extends DriveSyncError {
|
|
59
|
+
constructor(message?: string, opts?: DriveSyncErrorOptions);
|
|
60
|
+
}
|
|
61
|
+
/** Thrown when the Google Identity Services script never loaded within the timeout. */
|
|
62
|
+
export declare class GisLoadError extends Error {
|
|
63
|
+
constructor(message?: string);
|
|
64
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error classes for drive-sync. All carry structured fields (status,
|
|
3
|
+
* reason, email, fileId, retryAfter, etc.) rather than baking the only
|
|
4
|
+
* available information into a formatted message string, so callers can
|
|
5
|
+
* branch on error shape without string-parsing.
|
|
6
|
+
*/
|
|
7
|
+
export class DriveSyncError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
reason;
|
|
10
|
+
constructor(message, opts) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'DriveSyncError';
|
|
13
|
+
this.status = opts?.status;
|
|
14
|
+
this.reason = opts?.reason;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Thrown when no usable token exists and the requested call is
|
|
19
|
+
* non-interactive (so no popup/redirect flow may be triggered to obtain one).
|
|
20
|
+
*/
|
|
21
|
+
export class NeedsReauthError extends DriveSyncError {
|
|
22
|
+
constructor(message = 'Reauthentication required', opts) {
|
|
23
|
+
super(message, opts);
|
|
24
|
+
this.name = 'NeedsReauthError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Thrown on a 403 response with reason ACCESS_TOKEN_SCOPE_INSUFFICIENT.
|
|
29
|
+
*/
|
|
30
|
+
export class ScopeInsufficientError extends DriveSyncError {
|
|
31
|
+
constructor(message = 'Access token has insufficient scope', opts) {
|
|
32
|
+
super(message, { status: 403, reason: 'ACCESS_TOKEN_SCOPE_INSUFFICIENT', ...opts });
|
|
33
|
+
this.name = 'ScopeInsufficientError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Thrown when the authenticated account does not match the expected one
|
|
38
|
+
* (e.g. a silent refresh returned a token for a different Google account).
|
|
39
|
+
*/
|
|
40
|
+
export class WrongAccountError extends DriveSyncError {
|
|
41
|
+
expectedEmail;
|
|
42
|
+
actualEmail;
|
|
43
|
+
/** Convenience alias mirroring the spec's `this.email` field (== actualEmail). */
|
|
44
|
+
email;
|
|
45
|
+
constructor(opts) {
|
|
46
|
+
super(`Wrong account: expected ${opts.expectedEmail}${opts.actualEmail ? `, got ${opts.actualEmail}` : ''}`, opts);
|
|
47
|
+
this.name = 'WrongAccountError';
|
|
48
|
+
this.expectedEmail = opts.expectedEmail;
|
|
49
|
+
this.actualEmail = opts.actualEmail;
|
|
50
|
+
this.email = opts.actualEmail;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Thrown when a Drive file lookup returns 404. */
|
|
54
|
+
export class NotFoundError extends DriveSyncError {
|
|
55
|
+
fileId;
|
|
56
|
+
constructor(fileId, opts) {
|
|
57
|
+
super(`File not found: ${fileId}`, { status: 404, ...opts });
|
|
58
|
+
this.name = 'NotFoundError';
|
|
59
|
+
this.fileId = fileId;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Thrown on a 429 response. */
|
|
63
|
+
export class RateLimitedError extends DriveSyncError {
|
|
64
|
+
retryAfter;
|
|
65
|
+
constructor(opts) {
|
|
66
|
+
super('Rate limited', { status: 429, ...opts });
|
|
67
|
+
this.name = 'RateLimitedError';
|
|
68
|
+
this.retryAfter = opts?.retryAfter;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Generic 5xx-after-retries failure. */
|
|
72
|
+
export class TransientError extends DriveSyncError {
|
|
73
|
+
constructor(message = 'Transient upstream error', opts) {
|
|
74
|
+
super(message, opts);
|
|
75
|
+
this.name = 'TransientError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Thrown when the Google Identity Services script never loaded within the timeout. */
|
|
79
|
+
export class GisLoadError extends Error {
|
|
80
|
+
constructor(message = 'Google Identity Services failed to load in time') {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = 'GisLoadError';
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Logger } from './logger.js';
|
|
2
|
+
import type { FileRef } from './types.js';
|
|
3
|
+
/** Scopes this library always requests/requires. */
|
|
4
|
+
export declare const REQUIRED_SCOPES: string[];
|
|
5
|
+
interface BaseCallOptions {
|
|
6
|
+
appId: string;
|
|
7
|
+
projectId: string;
|
|
8
|
+
clientId: string;
|
|
9
|
+
interactive?: boolean;
|
|
10
|
+
logger?: Logger;
|
|
11
|
+
fetchEmail?: (accessToken: string) => Promise<string>;
|
|
12
|
+
}
|
|
13
|
+
export interface ReadOptions extends BaseCallOptions {
|
|
14
|
+
fileId: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Fetches a file's content. Returns `null` on a 404 rather than throwing,
|
|
18
|
+
* since Drive 404s both for a genuinely wrong id and for a file the
|
|
19
|
+
* connected account cannot see — this ambiguity is documented in the public
|
|
20
|
+
* API and callers are expected to treat `null` as "not available" rather
|
|
21
|
+
* than distinguishing the two cases.
|
|
22
|
+
*/
|
|
23
|
+
export declare function read(opts: ReadOptions): Promise<string | Blob | null>;
|
|
24
|
+
export interface RemoveOptions extends BaseCallOptions {
|
|
25
|
+
fileId: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function remove(opts: RemoveOptions): Promise<void>;
|
|
28
|
+
export interface WriteOptions extends BaseCallOptions {
|
|
29
|
+
fileId?: string;
|
|
30
|
+
folderId?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
content: string | Blob;
|
|
33
|
+
mimeType: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Creates or updates a file's content. Updates (fileId provided) use the
|
|
37
|
+
* media-upload endpoint with the raw body. Creates use a `FormData`
|
|
38
|
+
* multipart body — FormData + fetch computes and inserts its own boundary,
|
|
39
|
+
* so file content that happens to contain a literal boundary-looking string
|
|
40
|
+
* can never corrupt the request (a known bug in a hand-rolled-boundary
|
|
41
|
+
* implementation this replaces).
|
|
42
|
+
*/
|
|
43
|
+
export declare function write(opts: WriteOptions): Promise<FileRef>;
|
|
44
|
+
export interface ListOptions extends BaseCallOptions {
|
|
45
|
+
folderId?: string;
|
|
46
|
+
mimeType?: string;
|
|
47
|
+
nameEquals?: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function list(opts: ListOptions): Promise<FileRef[]>;
|
|
50
|
+
export interface EnsureFolderPathOptions extends BaseCallOptions {
|
|
51
|
+
folderPath: string[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Walks `folderPath` level by level, creating any missing folder along the
|
|
55
|
+
* way, and returns the leaf folder's id (never persisted by this library —
|
|
56
|
+
* callers get it fresh on every call).
|
|
57
|
+
*
|
|
58
|
+
* Root-level (first path segment) lookup design choice: Drive's "My Drive"
|
|
59
|
+
* root has no single well-known parent id that is safe to assume across
|
|
60
|
+
* every account/Shared-Drive configuration, and this library only has the
|
|
61
|
+
* `drive.file` scope (which only sees files/folders the app itself created
|
|
62
|
+
* or that were explicitly shared with it) — so instead of trying to anchor
|
|
63
|
+
* the first level under an assumed parent, we search WITHOUT a `... in
|
|
64
|
+
* parents` clause for the first level (`mimeType='...folder' and name='...'
|
|
65
|
+
* and trashed=false`), which finds the folder anywhere this app can see it.
|
|
66
|
+
* Every subsequent level uses the previous level's resolved id as an
|
|
67
|
+
* explicit `in parents` clause, which is unambiguous.
|
|
68
|
+
*/
|
|
69
|
+
export declare function ensureFolderPath(opts: EnsureFolderPathOptions): Promise<string>;
|
|
70
|
+
export {};
|