@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/dist/token.js ADDED
@@ -0,0 +1,140 @@
1
+ import { setToken, getToken } from './storage.js';
2
+ import { waitForGoogleIdentityServices } from './gis.js';
3
+ import { NeedsReauthError } from './errors.js';
4
+ import { createBroadcast } from './broadcast.js';
5
+ /**
6
+ * Persists a freshly-acquired GIS token response as a StoredToken, deriving
7
+ * expiresAt from the response's own expires_in (never hardcoded) and
8
+ * grantedScopes from the response's space-delimited scope string.
9
+ */
10
+ export async function persistTokenResponse(appId, projectId, response) {
11
+ const expiresIn = response.expires_in ?? 0;
12
+ const token = {
13
+ accessToken: response.access_token ?? '',
14
+ expiresAt: Date.now() + expiresIn * 1000,
15
+ grantedScopes: (response.scope ?? '').split(' ').filter((s) => s.length > 0),
16
+ };
17
+ await setToken(appId, projectId, token);
18
+ return token;
19
+ }
20
+ /**
21
+ * Key used for in-flight coalescing: per (projectId, sorted-scope-set), NOT
22
+ * global. This is what keeps concurrent calls for different projects (or
23
+ * different scope requirements within the same project) from colliding.
24
+ */
25
+ function coalesceKey(projectId, scopes) {
26
+ return `${projectId}|${scopes.slice().sort().join(' ')}`;
27
+ }
28
+ const inFlight = new Map();
29
+ /**
30
+ * Set of projectIds for which another tab has broadcast a fresh `token`
31
+ * message (see broadcast.ts / index.ts's `activate()`) that THIS tab has not
32
+ * yet consumed. Populated by `notifyExternalTokenRefresh` (called from
33
+ * index.ts's broadcast subscription) and drained by the next non-interactive
34
+ * `acquireToken` call for that project — a one-shot signal, not a durable
35
+ * cache (the durable copy is IndexedDB, read via `getToken`).
36
+ */
37
+ const externallyRefreshed = new Set();
38
+ /**
39
+ * Records that another tab just persisted a fresh token for `projectId` (via
40
+ * a cross-tab `token` broadcast). The NEXT non-interactive `acquireToken`
41
+ * call for this project will, instead of unconditionally starting a new GIS
42
+ * round-trip, first re-read the token this tab already shares via
43
+ * IndexedDB — skipping the redundant network request when that stored token
44
+ * turns out to already be usable.
45
+ */
46
+ export function notifyExternalTokenRefresh(projectId) {
47
+ externallyRefreshed.add(projectId);
48
+ }
49
+ /**
50
+ * Single entry point for acquiring a Drive access token, used by BOTH the
51
+ * interactive "connect" path (interactive: true -> prompt: 'consent', no
52
+ * hint) and the silent "refresh" path (interactive: false -> prompt: 'none',
53
+ * hint: the connection's known email).
54
+ *
55
+ * Design contract (see report): if `interactive` is false and GIS reports an
56
+ * error on the silent attempt, this function throws a NeedsReauthError
57
+ * itself (rather than pushing that decision to the caller). Callers that
58
+ * want to surface a different error type on the interactive path may catch
59
+ * and rethrow.
60
+ *
61
+ * Every call creates a FRESH `initTokenClient` — there is no module-level
62
+ * client, no module-level resolve/reject, and no module-level in-flight
63
+ * promise. Concurrent calls for different (projectId, scopes) pairs cannot
64
+ * collide; concurrent calls for the SAME (projectId, scopes) pair are
65
+ * coalesced onto a single in-flight promise (removed from the map in a
66
+ * `finally` once it settles).
67
+ */
68
+ export async function acquireToken(opts) {
69
+ // Non-interactive callers (silent refresh / warm-up) are the ones this is
70
+ // meant to help: an interactive `connect()` call is user-initiated and
71
+ // should never be silently swapped out for a stored token. If another tab
72
+ // signaled a fresh token for this project since we last checked, consume
73
+ // that signal and try storage first — a hit means no GIS round-trip at all.
74
+ if (!opts.interactive && externallyRefreshed.delete(opts.projectId)) {
75
+ const stored = await getToken(opts.appId, opts.projectId);
76
+ if (stored && stored.expiresAt > Date.now()) {
77
+ return stored;
78
+ }
79
+ }
80
+ const key = coalesceKey(opts.projectId, opts.scopes);
81
+ const existing = inFlight.get(key);
82
+ if (existing) {
83
+ return existing;
84
+ }
85
+ const promise = acquireTokenUncoalesced(opts);
86
+ inFlight.set(key, promise);
87
+ try {
88
+ return await promise;
89
+ }
90
+ finally {
91
+ inFlight.delete(key);
92
+ }
93
+ }
94
+ async function acquireTokenUncoalesced(opts) {
95
+ await waitForGoogleIdentityServices(opts.logger);
96
+ const w = globalThis;
97
+ const initTokenClient = w.google?.accounts?.oauth2?.initTokenClient;
98
+ if (!initTokenClient) {
99
+ // waitForGoogleIdentityServices resolved, so this should not happen in
100
+ // practice; guard anyway rather than throwing an obscure TypeError.
101
+ throw new NeedsReauthError('Google Identity Services is unavailable');
102
+ }
103
+ const response = await new Promise((resolve, reject) => {
104
+ // These resolve/reject are captured in THIS call's closure only, never
105
+ // stored on a module-level variable, so a second concurrent call cannot
106
+ // clobber the first caller's promise.
107
+ const client = initTokenClient({
108
+ client_id: opts.clientId,
109
+ scope: opts.scopes.join(' '),
110
+ callback: (res) => {
111
+ if (res.error) {
112
+ reject(new Error(`GIS token request failed: ${res.error}`));
113
+ return;
114
+ }
115
+ resolve(res);
116
+ },
117
+ });
118
+ opts.logger?.debug('drive-sync: requesting access token', {
119
+ projectId: opts.projectId,
120
+ interactive: opts.interactive,
121
+ });
122
+ client.requestAccessToken({
123
+ prompt: opts.interactive ? 'consent' : 'none',
124
+ hint: !opts.interactive ? opts.hint : undefined,
125
+ });
126
+ }).catch((err) => {
127
+ if (!opts.interactive) {
128
+ throw new NeedsReauthError('Silent token acquisition failed', { reason: 'gis_error' });
129
+ }
130
+ throw err;
131
+ });
132
+ const token = await persistTokenResponse(opts.appId, opts.projectId, response);
133
+ // Single choke point for the cross-tab "fresh token available" signal:
134
+ // every acquisition path (interactive connect(), silent refreshSilently(),
135
+ // and refresh.ts's direct warm-up call) funnels through here once the
136
+ // token is durably in IndexedDB, so other tabs re-read storage rather than
137
+ // receiving the access token itself over the channel.
138
+ createBroadcast(opts.appId).postToken(opts.projectId);
139
+ return token;
140
+ }
@@ -0,0 +1,42 @@
1
+ import type { Logger } from './logger.js';
2
+ export interface DriveSyncOptions {
3
+ appId: string;
4
+ clientId: string;
5
+ folderPath: string[];
6
+ logger?: Logger;
7
+ }
8
+ /**
9
+ * Durable connection state returned by getConnection(). Survives token
10
+ * expiry; does not contain any secret material.
11
+ */
12
+ export interface Connection {
13
+ email: string;
14
+ needsReauth: boolean;
15
+ expiresAt: number | null;
16
+ }
17
+ /**
18
+ * Ephemeral access token state, stored separately from the durable
19
+ * Connection record.
20
+ */
21
+ export interface StoredToken {
22
+ accessToken: string;
23
+ expiresAt: number;
24
+ grantedScopes: string[];
25
+ }
26
+ /** Representation of a Drive file reference. */
27
+ export interface FileRef {
28
+ id: string;
29
+ name?: string;
30
+ }
31
+ /** Drive permission shape as returned/accepted by the Drive Permissions API. */
32
+ export interface DrivePermission {
33
+ id: string;
34
+ type: 'user' | 'anyone';
35
+ role: string;
36
+ emailAddress?: string;
37
+ }
38
+ /** Options accepted on every Drive-op call site. */
39
+ export interface CallOptions {
40
+ /** Whether an interactive (popup/redirect) auth flow may be triggered. Defaults to false. */
41
+ interactive?: boolean;
42
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@open-webapp/drive-sync",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/open-webapp/owa",
8
+ "directory": "packages/drive-sync"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./testing": {
16
+ "types": "./dist/testing/index.d.ts",
17
+ "default": "./dist/testing/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md",
23
+ "SPEC.md"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "test": "vitest run"
28
+ },
29
+ "dependencies": {
30
+ "idb": "^8.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.6.0",
34
+ "vitest": "^2.1.0",
35
+ "fake-indexeddb": "^6.0.0"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }