@open-webapp/drive-sync 0.1.0 → 0.2.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/SPEC.md +6 -3
- package/dist/connection.d.ts +24 -0
- package/dist/connection.js +31 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +11 -1
- package/package.json +1 -1
package/SPEC.md
CHANGED
|
@@ -32,6 +32,7 @@ await p.connect(); // interactive; prompt:'consent
|
|
|
32
32
|
const conn = await p.getConnection(); // { email, needsReauth, expiresAt } | null
|
|
33
33
|
|
|
34
34
|
const folderId = await p.ensureFolderPath();
|
|
35
|
+
const token = await p.getAccessToken(); // raw token, for Google Picker's setOAuthToken() only
|
|
35
36
|
const files = await p.files.list({ folderId });
|
|
36
37
|
const text = await p.files.read(fileId); // string | Blob | null (null on 404)
|
|
37
38
|
const ref = await p.files.write({ folderId, name: 'x.json', content, mimeType: 'application/json' });
|
|
@@ -44,7 +45,9 @@ dispose();
|
|
|
44
45
|
|
|
45
46
|
`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
|
|
|
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
|
+
Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHandle`/`PermissionsHandle`), `connection.ts` (`connect`/`getConnection`/`disconnect`/`refreshSilently`/`getAccessToken`), `files.ts`, `permissions.ts`, `reconcile.ts`, `refresh.ts` (`activate`/warm-up), `errors.ts` (typed error classes), `types.ts` (`DriveSyncOptions`, `Connection`, `StoredToken`, `FileRef`, `DrivePermission`, `CallOptions`).
|
|
49
|
+
|
|
50
|
+
`getAccessToken()` is the one deliberate exception to `Connection` never exposing secret material (types.ts): it exists solely so an app can feed the token to Google Picker (`setOAuthToken()`), which runs outside this library's control and has no other way to read it. Reuses a cached token while it has more than 5 minutes left; otherwise acquires one (interactive by default, since callers use this to drive a UI the user is actively interacting with).
|
|
48
51
|
|
|
49
52
|
## 2. The 34 resolved design decisions
|
|
50
53
|
|
|
@@ -84,7 +87,7 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
|
|
|
84
87
|
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
88
|
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
89
|
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 —
|
|
90
|
+
31. **Cross-tab BroadcastChannel — fully 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. `connection.ts`'s `disconnect()` calls `postLogout`; `token.ts`'s `acquireToken` calls `postToken` after every successful acquisition (interactive `connect()`, silent `refreshSilently()`, and the plain warm-up fallback alike — one choke point right after the token lands in IndexedDB). `index.ts`'s `activate()` subscribes via `onMessage`: a `logout` message evicts this tab's cached IDB handle for that project (`evictDbHandle`) so a subsequent read sees the other tab's cleared storage; a `token` message calls `token.ts`'s `notifyExternalTokenRefresh(projectId)`, which lets this tab's next non-interactive `acquireToken` for that project skip its own GIS round-trip and re-read the fresh token from shared storage instead. Both directions are only live between `activate()` and its disposer — a project handle used without ever calling `.activate()` still reads/writes the same IndexedDB, just without the cross-tab shortcut.
|
|
88
91
|
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
92
|
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
93
|
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.
|
|
@@ -146,5 +149,5 @@ Wrong-account detection therefore covers exactly two silent paths — the 401-re
|
|
|
146
149
|
- **`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
150
|
- **`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
151
|
- **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
|
|
152
|
+
- **Cross-tab token sharing is best-effort, not a guarantee.** `notifyExternalTokenRefresh` (§4, decision #31) only ever skips ONE subsequent GIS round-trip per `token` broadcast received — a one-shot flag, not a durable "this project is externally fresh" cache. If two tabs both attempt a refresh in the same narrow window, both can still end up making their own GIS calls.
|
|
150
153
|
- **`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.
|
package/dist/connection.d.ts
CHANGED
|
@@ -63,6 +63,30 @@ export interface GetConnectionOptions {
|
|
|
63
63
|
* granted scopes on the stored connection are missing any required scope.
|
|
64
64
|
*/
|
|
65
65
|
export declare function getConnection(opts: GetConnectionOptions): Promise<Connection | null>;
|
|
66
|
+
export interface GetAccessTokenOptions {
|
|
67
|
+
appId: string;
|
|
68
|
+
projectId: string;
|
|
69
|
+
clientId: string;
|
|
70
|
+
scopes: string[];
|
|
71
|
+
/** Whether an interactive (popup) auth flow may be triggered if no usable cached token exists. */
|
|
72
|
+
interactive: boolean;
|
|
73
|
+
logger?: Logger;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Returns a raw OAuth access token for callers that must hand it directly to
|
|
77
|
+
* a Google-hosted widget this library does not control (namely Google
|
|
78
|
+
* Picker, which requires `setOAuthToken()`). This is a deliberate, narrow
|
|
79
|
+
* exception to Connection's "no secret material" contract documented in
|
|
80
|
+
* types.ts: Picker runs in Google's own popup/iframe and has no way to read
|
|
81
|
+
* a token this library keeps private, so the token must leave the library
|
|
82
|
+
* for that one integration to work at all. Callers should request this only
|
|
83
|
+
* to feed it straight to Picker, not to make their own Drive API calls
|
|
84
|
+
* (use `files`/`permissions` for that).
|
|
85
|
+
*
|
|
86
|
+
* Reuses a still-valid cached token as-is; otherwise acquires a fresh one
|
|
87
|
+
* via the normal token flow (interactive per `opts.interactive`).
|
|
88
|
+
*/
|
|
89
|
+
export declare function getAccessToken(opts: GetAccessTokenOptions): Promise<string>;
|
|
66
90
|
export interface DisconnectOptions {
|
|
67
91
|
appId: string;
|
|
68
92
|
projectId: string;
|
package/dist/connection.js
CHANGED
|
@@ -2,6 +2,8 @@ import { getConn, setConn, clearConn, getToken, clearToken } from './storage.js'
|
|
|
2
2
|
import { createBroadcast } from './broadcast.js';
|
|
3
3
|
import { acquireToken } from './token.js';
|
|
4
4
|
import { WrongAccountError } from './errors.js';
|
|
5
|
+
/** Mirrors refresh.ts's own buffer: a cached token this close to expiry is treated as unusable. */
|
|
6
|
+
const TOKEN_REUSE_BUFFER_MS = 5 * 60 * 1000;
|
|
5
7
|
/**
|
|
6
8
|
* Interactive connection flow: acquires a token with prompt: 'consent',
|
|
7
9
|
* resolves the account email, and persists the durable Connection record.
|
|
@@ -74,6 +76,35 @@ export async function getConnection(opts) {
|
|
|
74
76
|
expiresAt: token?.expiresAt ?? null,
|
|
75
77
|
};
|
|
76
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Returns a raw OAuth access token for callers that must hand it directly to
|
|
81
|
+
* a Google-hosted widget this library does not control (namely Google
|
|
82
|
+
* Picker, which requires `setOAuthToken()`). This is a deliberate, narrow
|
|
83
|
+
* exception to Connection's "no secret material" contract documented in
|
|
84
|
+
* types.ts: Picker runs in Google's own popup/iframe and has no way to read
|
|
85
|
+
* a token this library keeps private, so the token must leave the library
|
|
86
|
+
* for that one integration to work at all. Callers should request this only
|
|
87
|
+
* to feed it straight to Picker, not to make their own Drive API calls
|
|
88
|
+
* (use `files`/`permissions` for that).
|
|
89
|
+
*
|
|
90
|
+
* Reuses a still-valid cached token as-is; otherwise acquires a fresh one
|
|
91
|
+
* via the normal token flow (interactive per `opts.interactive`).
|
|
92
|
+
*/
|
|
93
|
+
export async function getAccessToken(opts) {
|
|
94
|
+
const cached = await getToken(opts.appId, opts.projectId);
|
|
95
|
+
if (cached && cached.expiresAt > Date.now() + TOKEN_REUSE_BUFFER_MS) {
|
|
96
|
+
return cached.accessToken;
|
|
97
|
+
}
|
|
98
|
+
const token = await acquireToken({
|
|
99
|
+
appId: opts.appId,
|
|
100
|
+
projectId: opts.projectId,
|
|
101
|
+
clientId: opts.clientId,
|
|
102
|
+
scopes: opts.scopes,
|
|
103
|
+
interactive: opts.interactive,
|
|
104
|
+
logger: opts.logger,
|
|
105
|
+
});
|
|
106
|
+
return token.accessToken;
|
|
107
|
+
}
|
|
77
108
|
/**
|
|
78
109
|
* Disconnects a project: revokes the cached token (if any and if a
|
|
79
110
|
* revokeFn was supplied), then unconditionally clears both the durable
|
package/dist/index.d.ts
CHANGED
|
@@ -40,6 +40,14 @@ export interface ProjectHandle {
|
|
|
40
40
|
getConnection(): Promise<Connection | null>;
|
|
41
41
|
disconnect(): Promise<void>;
|
|
42
42
|
ensureFolderPath(): Promise<string>;
|
|
43
|
+
/**
|
|
44
|
+
* Raw OAuth access token, for handing directly to Google Picker
|
|
45
|
+
* (`setOAuthToken()`) — see connection.ts's `getAccessToken` for why this
|
|
46
|
+
* is the one exception to this library otherwise never exposing token
|
|
47
|
+
* material. `interactive` defaults to true since callers use this to
|
|
48
|
+
* drive a UI the user is actively interacting with.
|
|
49
|
+
*/
|
|
50
|
+
getAccessToken(callOpts?: CallOptions): Promise<string>;
|
|
43
51
|
files: FilesHandle;
|
|
44
52
|
permissions: PermissionsHandle;
|
|
45
53
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { noOpLogger } from './logger.js';
|
|
2
|
-
import { connect as connectImpl, getConnection as getConnectionImpl, disconnect as disconnectImpl } from './connection.js';
|
|
2
|
+
import { connect as connectImpl, getConnection as getConnectionImpl, disconnect as disconnectImpl, getAccessToken as getAccessTokenImpl } from './connection.js';
|
|
3
3
|
import { reconcile as reconcileImpl, dropProject as dropProjectImpl } from './reconcile.js';
|
|
4
4
|
import * as filesImpl from './files.js';
|
|
5
5
|
import * as permissionsImpl from './permissions.js';
|
|
@@ -194,6 +194,16 @@ export function createDriveSync(options) {
|
|
|
194
194
|
ensureFolderPath() {
|
|
195
195
|
return filesImpl.ensureFolderPath({ ...base, folderPath });
|
|
196
196
|
},
|
|
197
|
+
getAccessToken(callOpts) {
|
|
198
|
+
return getAccessTokenImpl({
|
|
199
|
+
appId,
|
|
200
|
+
projectId,
|
|
201
|
+
clientId,
|
|
202
|
+
scopes: REQUIRED_SCOPES,
|
|
203
|
+
interactive: callOpts?.interactive ?? true,
|
|
204
|
+
logger,
|
|
205
|
+
});
|
|
206
|
+
},
|
|
197
207
|
files,
|
|
198
208
|
permissions,
|
|
199
209
|
};
|