@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 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 — 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`).
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 `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.
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.
@@ -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;
@@ -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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/drive-sync",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",