@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/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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|