@mcpher/gas-fakes 2.2.3 → 2.2.4
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/.msgraph-token.json +2 -2
- package/README.md +7 -0
- package/introvideo.png +0 -0
- package/package.json +1 -7
- package/src/cli/app.js +4 -2
- package/src/cli/setup.js +1 -0
- package/src/services/driveapp/fakedrivemeta.js +41 -15
- package/src/services/enums/platformenums.js +9 -0
- package/src/services/libhandlerapp/fakelibrary.js +11 -4
- package/src/services/scriptapp/app.js +1 -1
- package/src/services/scriptapp/behavior.js +69 -25
- package/src/support/auth.js +2 -3
- package/src/support/msgraph/msauth.js +95 -50
- package/src/support/msgraph/onedrive.js +2 -1
- package/src/support/syncit.js +3 -2
package/.msgraph-token.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"token": "
|
|
3
|
-
"expiresOn":
|
|
2
|
+
"token": "EwBIBMl6BAAU9BatlgMxts2T1B5e3Mucgfs4jcAAAQbE+FWxF2964+qK5G5uaQ3pUy/MCJC0oOchzzIsAfEdZe5onY2EsmZQ4PATW2I6PuEagYUz7AKj565RuN8TB7q1URYyKGIajrSzsf1QSnzdOZqMr+dssABUDNjK+u8IEJViuglmAwJOIRzxRVsu3/cVoNzAi1V/RWmeQPD/BR7lUhmmNbQ0YYvlooW5GDu5M+vk3YZiz3ejDeHmFTJiMwHDohEKesRv4sVDXiFwukVOXtQ5C1ZRRt4y7zWJJyrIwHlmOYrhB33J9pa4xyRH7jXeNk04rnvTnFCLnrYLWqdlzOyWlJDzOQi7Y6rOubBMgkflSCk/cVLR6MO3YXQL5LkQZgAAEORwrlKhRbHoA3g6b8T1IkAQA99XwtXwfOoj8PC9RMUXN4qVo0gWDk1Lt/bsl9eqCQNkeXi/Li9mdnt+SLJa0DW50Gpk4OOVdH2SZ96d7aAUA30oLLsi5rdUD1TrjWLPO4uYeqvVOsT61whhVQJNHKsVQQcG3Cmm3iWGl+c7G0K/QZuNNvTNKh6GynIyNDJwVNRuk8FbqkC+WA2GnolDjstc28D2MZpFRQ7A0fG9Ik4tZUNtmUHWLKn6SQum6QVqbUGo4dayFwD/kYEUOYi/YVLmBZyVSpZAjbTFTNZqRp8rZzB3XOD7+ih362c/6ngEp7SpD55lLS0rtQUrByDQNXuLKcRjfNTv39YhgwGvDX9pARUK7Xu18t3gBt593nl3eDojClyCwlIDVzxR7UUGyZH1xYojiCPvWqsvnf6hxZZXDbNYWZt4xs/kjnKCr/5A7C5V8990/Zpxe52JbHDMxbCqoyl8L3CpTVymAyuP3PtwDXUBJfD20kvSNGfH6UiTX5uwCUr7MbhEwzAAnPehDVQcOFJ928WDWA3GPEOnK4+sQWl3M6W8CZPXGW3oHj8BraOu5yS226zT9MOQJSXPAiuFTaFOPsX+MJZqYectkSuJUa2ZbKYtSntbS3LAkv0bukTgDz+CwfXa/Sqq5dZtfx4CB1hM+fUsmBbcSQEi6BJnYpfyhCkU26NfTRhZv7sjbP9TGUAGtMuOHZSwxqflPAr2EdeYcaRt68pwQAtwrFQAkyeS9QiDItpVE3FMS6neWd07Yhua/ZIYzd2U8aSUQuHaEb74nqt2IvWZkUAkTA1RhRxDtSGtLEv3drrrLCkRSOP8gnCxJqVvMZz6nkXUI5lJIY57XP9n72n05nXvrSDMSuEi7fAHTgX0B80yAZywVSQgtBs/IPrkhV5ih16Py6CC5kf3mQBviITgBdgScvTi0FxAfpDRPNVTHrCCmvPGy4XG5d01YAkZh1OwIaZ/M01GspinBgVLVpH5wdPUnwYIOMHNk956KWQmDCU2TLWQv/kM2e898XIk0cmQ8PTUukOwP46Qjx1n9ZkRMoxvtwd6bAJMAw==",
|
|
3
|
+
"expiresOn": 1773667833000
|
|
4
4
|
}
|
package/README.md
CHANGED
|
@@ -161,6 +161,13 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
|
|
|
161
161
|
|
|
162
162
|
## <img src="./logo.png" alt="gas-fakes logo" width="50" align="top"> Further Reading
|
|
163
163
|
|
|
164
|
+
## Watch the video
|
|
165
|
+
|
|
166
|
+
[](https://youtu.be/oEjpIrkYpEM)
|
|
167
|
+
|
|
168
|
+
## Read more docs
|
|
169
|
+
|
|
170
|
+
- [gas fakes intro video](https://youtu.be/oEjpIrkYpEM)
|
|
164
171
|
- [getting started](GETTING_STARTED.md) - how to handle authentication for restricted scopes.
|
|
165
172
|
- [readme](README.md)
|
|
166
173
|
- [gas fakes cli](gas-fakes-cli.md)
|
package/introvideo.png
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -26,12 +26,6 @@
|
|
|
26
26
|
"unzipper": "^0.12.3",
|
|
27
27
|
"zod": "^4.3.6"
|
|
28
28
|
},
|
|
29
|
-
"overrides": {
|
|
30
|
-
"glob": "^13.0.0",
|
|
31
|
-
"rimraf": "^6.1.3",
|
|
32
|
-
"minimatch": "^10.2.1",
|
|
33
|
-
"node-domexception": "^1.0.0"
|
|
34
|
-
},
|
|
35
29
|
"type": "module",
|
|
36
30
|
"scripts": {
|
|
37
31
|
"pub": "npm publish --access public",
|
|
@@ -41,7 +35,7 @@
|
|
|
41
35
|
},
|
|
42
36
|
"name": "@mcpher/gas-fakes",
|
|
43
37
|
"author": "bruce mcpherson",
|
|
44
|
-
"version": "2.2.
|
|
38
|
+
"version": "2.2.4",
|
|
45
39
|
"license": "MIT",
|
|
46
40
|
"main": "main.js",
|
|
47
41
|
"description": "An implementation of the Google Workspace Apps Script runtime: Run native App Script Code on Node and Cloud Run",
|
package/src/cli/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
enableGoogleAPIs,
|
|
11
11
|
} from "./setup.js";
|
|
12
12
|
import { startMcpServer } from "./mcp.js";
|
|
13
|
+
import { Platforms, PlatformDefaults } from "../services/enums/platformenums.js";
|
|
13
14
|
|
|
14
15
|
export async function main() {
|
|
15
16
|
const program = new Command();
|
|
@@ -121,11 +122,12 @@ export async function main() {
|
|
|
121
122
|
});
|
|
122
123
|
|
|
123
124
|
// --- Setup Commands ---
|
|
125
|
+
const validPlatforms = Object.values(Platforms).join(", ");
|
|
124
126
|
program
|
|
125
127
|
.command("init")
|
|
126
128
|
.description("Initializes the configuration (.env).")
|
|
127
129
|
.option("-e, --env <path>", "Path to a custom .env file.")
|
|
128
|
-
.option("-b, --backends <string...>",
|
|
130
|
+
.option("-b, --backends <string...>", `List of backends to initialize (${validPlatforms}).`, [PlatformDefaults.DEFAULT])
|
|
129
131
|
.addOption(
|
|
130
132
|
new Option(
|
|
131
133
|
"--at,--auth-type <string>",
|
|
@@ -139,7 +141,7 @@ export async function main() {
|
|
|
139
141
|
program
|
|
140
142
|
.command("auth")
|
|
141
143
|
.description("Runs the authentication flow for a backend.")
|
|
142
|
-
.option("-b, --backend <string>",
|
|
144
|
+
.option("-b, --backend <string>", `Backend to authenticate (${validPlatforms}). Defaults to configured platforms.`)
|
|
143
145
|
.action(authenticateUser);
|
|
144
146
|
|
|
145
147
|
program
|
package/src/cli/setup.js
CHANGED
|
@@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto";
|
|
|
7
7
|
import { execSync } from "child_process";
|
|
8
8
|
import { checkForGcloudCli, checkForAzCli, runCommandSync } from "./utils.js";
|
|
9
9
|
import { getMsGraphToken, mapGasScopesToMsGraph } from "../support/msgraph/msauth.js";
|
|
10
|
+
import { Platforms, PlatformDefaults } from "../services/enums/platformenums.js";
|
|
10
11
|
|
|
11
12
|
// --- Utility Functions ---
|
|
12
13
|
|
|
@@ -32,6 +32,32 @@ export class FakeDriveMeta {
|
|
|
32
32
|
constructor(meta) {
|
|
33
33
|
this.meta = meta
|
|
34
34
|
this.__gas_fake_service = "DriveApp"
|
|
35
|
+
// The resource platform takes precedence over ScriptApp.__platform.
|
|
36
|
+
// ScriptApp.__platform is only used to determine the backend for new resources.
|
|
37
|
+
this.platform = ScriptApp.__platform || "google"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Internal helper to get the platform ID.
|
|
42
|
+
* @returns {string} The platform ID.
|
|
43
|
+
*/
|
|
44
|
+
__getPlatform() {
|
|
45
|
+
return this.platform
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Execute a function within the context of this resource's platform.
|
|
50
|
+
* This ensures the correct backend is used regardless of the global ScriptApp.__platform setting.
|
|
51
|
+
* @param {function} fn
|
|
52
|
+
*/
|
|
53
|
+
__withPlatform(fn) {
|
|
54
|
+
const currentPlatform = ScriptApp.__platform;
|
|
55
|
+
try {
|
|
56
|
+
ScriptApp.__platform = this.platform;
|
|
57
|
+
return fn();
|
|
58
|
+
} finally {
|
|
59
|
+
ScriptApp.__platform = currentPlatform;
|
|
60
|
+
}
|
|
35
61
|
}
|
|
36
62
|
|
|
37
63
|
__preventRootDamage = (operation) => {
|
|
@@ -62,7 +88,7 @@ export class FakeDriveMeta {
|
|
|
62
88
|
return this
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
const newMeta = Drive.Files.get(this.getId(), { fields }, { allow404: false })
|
|
91
|
+
const newMeta = this.__withPlatform(() => Drive.Files.get(this.getId(), { fields }, { allow404: false }))
|
|
66
92
|
// need to merge this with already known fields
|
|
67
93
|
this.meta = { ...this.meta, ...newMeta }
|
|
68
94
|
improveFileCache(this.getId(), this.meta, fields)
|
|
@@ -103,7 +129,7 @@ export class FakeDriveMeta {
|
|
|
103
129
|
const file = {}
|
|
104
130
|
file[prop] = value
|
|
105
131
|
|
|
106
|
-
const data = Drive.Files.update(file, this.getId(), null, prop)
|
|
132
|
+
const data = this.__withPlatform(() => Drive.Files.update(file, this.getId(), null, prop))
|
|
107
133
|
this.meta = { ...this.meta, ...data }
|
|
108
134
|
improveFileCache(this.getId(), data)
|
|
109
135
|
|
|
@@ -232,7 +258,7 @@ export class FakeDriveMeta {
|
|
|
232
258
|
}
|
|
233
259
|
|
|
234
260
|
// need to make sure we get the new parents field back to improve cache with
|
|
235
|
-
const data = Drive.Files.update({}, this.getId(), null, "parents", params)
|
|
261
|
+
const data = this.__withPlatform(() => Drive.Files.update({}, this.getId(), null, "parents", params))
|
|
236
262
|
|
|
237
263
|
// merge this with already known fields and improve cache
|
|
238
264
|
this.meta = { ...this.meta, ...data }
|
|
@@ -294,10 +320,10 @@ export class FakeDriveMeta {
|
|
|
294
320
|
allowFileDiscovery = (access === Access.DOMAIN);
|
|
295
321
|
} else if (access === Access.PRIVATE) {
|
|
296
322
|
// For PRIVATE, we typically remove any 'anyone' or 'domain' permissions.
|
|
297
|
-
const { permissions } = Drive.Permissions.list(this.getId());
|
|
323
|
+
const { permissions } = this.__withPlatform(() => Drive.Permissions.list(this.getId()));
|
|
298
324
|
permissions.forEach(p => {
|
|
299
325
|
if (p.type === 'anyone' || p.type === 'domain') {
|
|
300
|
-
Drive.Permissions.delete(this.getId(), p.id);
|
|
326
|
+
this.__withPlatform(() => Drive.Permissions.delete(this.getId(), p.id));
|
|
301
327
|
}
|
|
302
328
|
});
|
|
303
329
|
return this;
|
|
@@ -315,9 +341,9 @@ export class FakeDriveMeta {
|
|
|
315
341
|
}
|
|
316
342
|
|
|
317
343
|
// 3. Find existing permission of this type or create new
|
|
318
|
-
const { permissions } = Drive.Permissions.list(this.getId(), {
|
|
344
|
+
const { permissions } = this.__withPlatform(() => Drive.Permissions.list(this.getId(), {
|
|
319
345
|
fields: "permissions(id,role,type,allowFileDiscovery,domain)"
|
|
320
|
-
});
|
|
346
|
+
}));
|
|
321
347
|
const existing = permissions.find(p => p.type === type);
|
|
322
348
|
|
|
323
349
|
if (existing) {
|
|
@@ -327,18 +353,18 @@ export class FakeDriveMeta {
|
|
|
327
353
|
(type === 'domain' && existing.domain !== domain);
|
|
328
354
|
|
|
329
355
|
if (identityChanged) {
|
|
330
|
-
Drive.Permissions.delete(this.getId(), existing.id);
|
|
356
|
+
this.__withPlatform(() => Drive.Permissions.delete(this.getId(), existing.id));
|
|
331
357
|
const resource = { role, type, allowFileDiscovery };
|
|
332
358
|
if (type === 'domain') resource.domain = domain;
|
|
333
|
-
Drive.Permissions.create(resource, this.getId());
|
|
359
|
+
this.__withPlatform(() => Drive.Permissions.create(resource, this.getId()));
|
|
334
360
|
} else {
|
|
335
361
|
// Only role is writable in update
|
|
336
|
-
Drive.Permissions.update({ role }, this.getId(), existing.id);
|
|
362
|
+
this.__withPlatform(() => Drive.Permissions.update({ role }, this.getId(), existing.id));
|
|
337
363
|
}
|
|
338
364
|
} else {
|
|
339
365
|
const resource = { role, type, allowFileDiscovery };
|
|
340
366
|
if (type === 'domain') resource.domain = Session.getActiveUser().getDomain();
|
|
341
|
-
Drive.Permissions.create(resource, this.getId());
|
|
367
|
+
this.__withPlatform(() => Drive.Permissions.create(resource, this.getId()));
|
|
342
368
|
}
|
|
343
369
|
|
|
344
370
|
improveFileCache(this.getId(), null);
|
|
@@ -379,18 +405,18 @@ export class FakeDriveMeta {
|
|
|
379
405
|
// In Apps Script, this is not atomic. It just loops.
|
|
380
406
|
emailAddresses.forEach(emailAddress => {
|
|
381
407
|
const resource = { role, type: 'user', emailAddress };
|
|
382
|
-
Drive.Permissions.create(resource, this.getId());
|
|
408
|
+
this.__withPlatform(() => Drive.Permissions.create(resource, this.getId()));
|
|
383
409
|
});
|
|
384
410
|
} else {
|
|
385
411
|
// To remove, we need to find the permission ID for each email.
|
|
386
|
-
const { permissions } = Drive.Permissions.list(this.getId(), {
|
|
412
|
+
const { permissions } = this.__withPlatform(() => Drive.Permissions.list(this.getId(), {
|
|
387
413
|
fields: 'permissions(id,role,emailAddress)'
|
|
388
|
-
});
|
|
414
|
+
}));
|
|
389
415
|
|
|
390
416
|
emailAddresses.forEach(emailAddress => {
|
|
391
417
|
const permission = permissions.find(p => p.emailAddress === emailAddress && p.role === role);
|
|
392
418
|
if (permission) {
|
|
393
|
-
Drive.Permissions.delete(this.getId(), permission.id);
|
|
419
|
+
this.__withPlatform(() => Drive.Permissions.delete(this.getId(), permission.id));
|
|
394
420
|
}
|
|
395
421
|
// Apps Script doesn't throw an error if the user isn't found.
|
|
396
422
|
});
|
|
@@ -35,10 +35,17 @@ class FakeLibrary {
|
|
|
35
35
|
get libContent() {
|
|
36
36
|
if (!this.__libContent) {
|
|
37
37
|
this.__allowSandboxAccess();
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
const currentPlatform = ScriptApp.__platform;
|
|
39
|
+
let data;
|
|
40
|
+
try {
|
|
41
|
+
ScriptApp.__platform = 'google';
|
|
42
|
+
data = Drive.Files.export(
|
|
43
|
+
this.libraryId,
|
|
44
|
+
'application/vnd.google-apps.script+json',
|
|
45
|
+
);
|
|
46
|
+
} finally {
|
|
47
|
+
ScriptApp.__platform = currentPlatform;
|
|
48
|
+
}
|
|
42
49
|
if (!data) {
|
|
43
50
|
throw new Error(`Library ${this.libraryId} not found`);
|
|
44
51
|
}
|
|
@@ -251,13 +251,13 @@ class FakeBehavior {
|
|
|
251
251
|
// this is a set of all the files this instance of gas-fakes has created
|
|
252
252
|
// the idea is that we can use this to clean up after tests or to emulate drive.file scope
|
|
253
253
|
// key is the file id
|
|
254
|
-
this.__createdIds = new
|
|
255
|
-
this.__createdGmailIds = new
|
|
256
|
-
this.__createdCalendarIds = new
|
|
254
|
+
this.__createdIds = new Map();
|
|
255
|
+
this.__createdGmailIds = new Map();
|
|
256
|
+
this.__createdCalendarIds = new Map();
|
|
257
257
|
// Specifically for sandbox mode - files created when sandbox mode is on
|
|
258
|
-
this.__allowedIds = new
|
|
259
|
-
this.__allowedGmailIds = new
|
|
260
|
-
this.__allowedCalendarIds = new
|
|
258
|
+
this.__allowedIds = new Map();
|
|
259
|
+
this.__allowedGmailIds = new Map();
|
|
260
|
+
this.__allowedCalendarIds = new Map();
|
|
261
261
|
// in sandbox mode we only allow access to files created in this instance
|
|
262
262
|
// this is to emulate the behavior of a drive.file scope
|
|
263
263
|
this.__sandboxMode = false;
|
|
@@ -406,40 +406,75 @@ class FakeBehavior {
|
|
|
406
406
|
if (isRootId) return id;
|
|
407
407
|
|
|
408
408
|
if (this.sandboxMode || force) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
this.__allowedIds.add(id);
|
|
413
|
-
}
|
|
409
|
+
const platform = ScriptApp.__platform;
|
|
410
|
+
this.__createdIds.set(id, platform);
|
|
411
|
+
this.whitelistFile(id);
|
|
414
412
|
}
|
|
415
413
|
return id
|
|
416
414
|
}
|
|
415
|
+
|
|
416
|
+
whitelistFile(id) {
|
|
417
|
+
if (!is.nonEmptyString(id)) {
|
|
418
|
+
throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
|
|
419
|
+
}
|
|
420
|
+
const isRootId = id === 'root' || (globalThis.DriveApp?.getRootFolder()?.getId() === id);
|
|
421
|
+
if (isRootId) return id;
|
|
422
|
+
|
|
423
|
+
const platform = ScriptApp.__platform;
|
|
424
|
+
if (!this.__allowedIds.has(id)) {
|
|
425
|
+
slogger.log(`...whitelisting file ${id} on ${platform}`);
|
|
426
|
+
this.__allowedIds.set(id, platform);
|
|
427
|
+
}
|
|
428
|
+
return id;
|
|
429
|
+
}
|
|
430
|
+
|
|
417
431
|
addGmailId(id, force = false) {
|
|
418
432
|
if (!is.nonEmptyString(id)) {
|
|
419
433
|
throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
|
|
420
434
|
}
|
|
421
435
|
if (this.sandboxMode || force) {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
this.__allowedGmailIds.add(id);
|
|
426
|
-
}
|
|
436
|
+
const platform = ScriptApp.__platform;
|
|
437
|
+
this.__createdGmailIds.set(id, platform);
|
|
438
|
+
this.whitelistGmailId(id);
|
|
427
439
|
}
|
|
428
440
|
return id
|
|
429
441
|
}
|
|
442
|
+
|
|
443
|
+
whitelistGmailId(id) {
|
|
444
|
+
if (!is.nonEmptyString(id)) {
|
|
445
|
+
throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
|
|
446
|
+
}
|
|
447
|
+
const platform = ScriptApp.__platform;
|
|
448
|
+
if (!this.__allowedGmailIds.has(id)) {
|
|
449
|
+
slogger.log(`...whitelisting gmail id ${id} on ${platform}`);
|
|
450
|
+
this.__allowedGmailIds.set(id, platform);
|
|
451
|
+
}
|
|
452
|
+
return id;
|
|
453
|
+
}
|
|
454
|
+
|
|
430
455
|
addCalendarId(id, force = false) {
|
|
431
456
|
if (!is.nonEmptyString(id)) {
|
|
432
457
|
throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
|
|
433
458
|
}
|
|
434
459
|
if (this.sandboxMode || force) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
this.__allowedCalendarIds.add(id);
|
|
439
|
-
}
|
|
460
|
+
const platform = ScriptApp.__platform;
|
|
461
|
+
this.__createdCalendarIds.set(id, platform);
|
|
462
|
+
this.whitelistCalendarId(id);
|
|
440
463
|
}
|
|
441
464
|
return id
|
|
442
465
|
}
|
|
466
|
+
|
|
467
|
+
whitelistCalendarId(id) {
|
|
468
|
+
if (!is.nonEmptyString(id)) {
|
|
469
|
+
throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
|
|
470
|
+
}
|
|
471
|
+
const platform = ScriptApp.__platform;
|
|
472
|
+
if (!this.__allowedCalendarIds.has(id)) {
|
|
473
|
+
slogger.log(`...whitelisting calendar id ${id} on ${platform}`);
|
|
474
|
+
this.__allowedCalendarIds.set(id, platform);
|
|
475
|
+
}
|
|
476
|
+
return id;
|
|
477
|
+
}
|
|
443
478
|
isAccessible(id, serviceName, accessType = 'read') {
|
|
444
479
|
if (!is.nonEmptyString(id)) {
|
|
445
480
|
throw new Error(`API call to ${serviceName} failed with error: Invalid argument: id`);
|
|
@@ -528,21 +563,24 @@ class FakeBehavior {
|
|
|
528
563
|
trash() {
|
|
529
564
|
let trashed = [];
|
|
530
565
|
const wasSandbox = this.sandboxMode;
|
|
566
|
+
const currentPlatform = ScriptApp.__platform;
|
|
531
567
|
this.sandboxMode = false;
|
|
532
568
|
try {
|
|
533
569
|
// Drive cleanup
|
|
534
570
|
if (this.__cleanup) {
|
|
535
571
|
const rootId = DriveApp.getRootFolder().getId();
|
|
536
|
-
trashed = Array.from(this.__createdIds).reduce((acc, id) => {
|
|
572
|
+
trashed = Array.from(this.__createdIds.entries()).reduce((acc, [id, platform]) => {
|
|
537
573
|
if (id === rootId) {
|
|
538
574
|
slogger.log(`...skipping trashing of root folder`);
|
|
539
575
|
return acc;
|
|
540
576
|
}
|
|
541
577
|
let d = null
|
|
542
578
|
try {
|
|
579
|
+
ScriptApp.__platform = platform;
|
|
543
580
|
d = DriveApp.getFileById(id)
|
|
544
581
|
} catch (e) {
|
|
545
582
|
try {
|
|
583
|
+
ScriptApp.__platform = platform;
|
|
546
584
|
d = DriveApp.getFolderById(id)
|
|
547
585
|
} catch (ee) {
|
|
548
586
|
// Ignore if not found
|
|
@@ -550,10 +588,11 @@ class FakeBehavior {
|
|
|
550
588
|
}
|
|
551
589
|
if (d && d.getId() !== rootId) {
|
|
552
590
|
try {
|
|
591
|
+
ScriptApp.__platform = platform;
|
|
553
592
|
d.setTrashed(true);
|
|
554
593
|
const name = d.getName();
|
|
555
594
|
const logLabel = name ? `${name} (${id})` : id;
|
|
556
|
-
slogger.log(`...trashed file ${logLabel}`);
|
|
595
|
+
slogger.log(`...trashed file ${logLabel} on ${platform}`);
|
|
557
596
|
acc.push(id);
|
|
558
597
|
} catch (e) {
|
|
559
598
|
slogger.error(`...failed to trash file ${id}: ${e.message}`);
|
|
@@ -573,8 +612,9 @@ class FakeBehavior {
|
|
|
573
612
|
const gmailCleanup = gmailSettings && gmailSettings.cleanup; // This will return true/false (inherits or specific)
|
|
574
613
|
|
|
575
614
|
if (gmailCleanup) {
|
|
576
|
-
trashedGmail = Array.from(this.__createdGmailIds).reduce((acc, id) => {
|
|
615
|
+
trashedGmail = Array.from(this.__createdGmailIds.entries()).reduce((acc, [id, platform]) => {
|
|
577
616
|
try {
|
|
617
|
+
ScriptApp.__platform = platform;
|
|
578
618
|
// Try as label
|
|
579
619
|
Gmail.Users.Labels.remove('me', id);
|
|
580
620
|
slogger.log(`...deleted gmail label ${id}`);
|
|
@@ -583,6 +623,7 @@ class FakeBehavior {
|
|
|
583
623
|
} catch (e) { /* not a label or failed */ }
|
|
584
624
|
|
|
585
625
|
try {
|
|
626
|
+
ScriptApp.__platform = platform;
|
|
586
627
|
// Try as thread - move to trash
|
|
587
628
|
Gmail.Users.Threads.trash('me', id);
|
|
588
629
|
slogger.log(`...trashed gmail thread ${id}`);
|
|
@@ -591,6 +632,7 @@ class FakeBehavior {
|
|
|
591
632
|
} catch (e) { /* not a thread */ }
|
|
592
633
|
|
|
593
634
|
try {
|
|
635
|
+
ScriptApp.__platform = platform;
|
|
594
636
|
Gmail.Users.Messages.trash('me', id);
|
|
595
637
|
slogger.log(`...trashed gmail message ${id}`);
|
|
596
638
|
acc.push(id);
|
|
@@ -611,8 +653,9 @@ class FakeBehavior {
|
|
|
611
653
|
const calendarCleanup = calendarSettings && calendarSettings.cleanup;
|
|
612
654
|
|
|
613
655
|
if (calendarCleanup) {
|
|
614
|
-
trashedCalendars = Array.from(this.__createdCalendarIds).reduce((acc, id) => {
|
|
656
|
+
trashedCalendars = Array.from(this.__createdCalendarIds.entries()).reduce((acc, [id, platform]) => {
|
|
615
657
|
try {
|
|
658
|
+
ScriptApp.__platform = platform;
|
|
616
659
|
// Delete calendar
|
|
617
660
|
Calendar.Calendars.delete(id, { noLog404: true });
|
|
618
661
|
slogger.log(`...deleted calendar ${id}`);
|
|
@@ -631,6 +674,7 @@ class FakeBehavior {
|
|
|
631
674
|
slogger.log(`...trashed ${trashed.length} sandboxed files, ${trashedGmail.length} gmail items, and ${trashedCalendars.length} calendars`);
|
|
632
675
|
} finally {
|
|
633
676
|
this.sandboxMode = wasSandbox;
|
|
677
|
+
ScriptApp.__platform = currentPlatform;
|
|
634
678
|
}
|
|
635
679
|
return trashed;
|
|
636
680
|
}
|
package/src/support/auth.js
CHANGED
|
@@ -6,9 +6,8 @@ import { clearFileCache } from "./filecache.js";
|
|
|
6
6
|
|
|
7
7
|
// Multi-identity storage
|
|
8
8
|
export const _identities = new Map();
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
let _platform = process.env.GF_PLATFORM_AUTH ? process.env.GF_PLATFORM_AUTH.split(',')[0] : 'google';
|
|
9
|
+
// Prefer 'google' as the default platform if it is authorized in the environment
|
|
10
|
+
let _platform = process.env.GF_PLATFORM_AUTH ? (process.env.GF_PLATFORM_AUTH.includes('google') ? 'google' : process.env.GF_PLATFORM_AUTH.split(',')[0]) : 'google';
|
|
12
11
|
let _manifest = null;
|
|
13
12
|
let _clasp = null;
|
|
14
13
|
let _settings = null;
|
|
@@ -7,6 +7,7 @@ import { readFile, writeFile } from 'fs/promises';
|
|
|
7
7
|
import { existsSync } from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import { chmodSync } from 'fs';
|
|
10
|
+
import got from 'got';
|
|
10
11
|
|
|
11
12
|
const TOKEN_CACHE_FILE = path.join(process.cwd(), '.msgraph-token.json');
|
|
12
13
|
|
|
@@ -71,6 +72,11 @@ export function mapGasScopesToMsGraph(gasScopes = []) {
|
|
|
71
72
|
return Array.from(msScopes);
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
async function isGcpEnv() {
|
|
76
|
+
if (process.env.K_SERVICE || process.env.FUNCTION_NAME) return true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
74
80
|
/**
|
|
75
81
|
* Gets a Microsoft Graph token.
|
|
76
82
|
*/
|
|
@@ -78,13 +84,9 @@ export async function getMsGraphToken(scopes = ['User.Read']) {
|
|
|
78
84
|
const envTenant = process.env.MS_GRAPH_TENANT_ID || 'common';
|
|
79
85
|
const clientId = process.env.MS_GRAPH_CLIENT_ID;
|
|
80
86
|
const clientSecret = process.env.MS_GRAPH_CLIENT_SECRET;
|
|
87
|
+
const msAuthType = process.env.MS_AUTH_TYPE;
|
|
81
88
|
|
|
82
|
-
// No local token caching - strictly "Keyless"
|
|
83
|
-
|
|
84
|
-
// Ensure no duplicate or empty scopes
|
|
85
89
|
const uniqueScopes = Array.from(new Set(scopes)).filter(s => s);
|
|
86
|
-
|
|
87
|
-
// Format for MS Graph: core scopes are as are, resource scopes with full URL prefix
|
|
88
90
|
const msScopes = uniqueScopes.map(s => {
|
|
89
91
|
const core = ['openid', 'profile', 'email', 'offline_access'];
|
|
90
92
|
if (core.some(c => s.startsWith(c))) return s;
|
|
@@ -92,17 +94,63 @@ export async function getMsGraphToken(scopes = ['User.Read']) {
|
|
|
92
94
|
return `https://graph.microsoft.com/${s.startsWith('/') ? s.slice(1) : s}`;
|
|
93
95
|
});
|
|
94
96
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
// Federated/WIF bypasses cache to ensure fresh identity exchange
|
|
98
|
+
if (msAuthType !== 'federated') {
|
|
99
|
+
const cachedToken = await loadTokenCache();
|
|
100
|
+
if (cachedToken) {
|
|
101
|
+
syncLog('...retrieved MS Graph token via local file cache');
|
|
102
|
+
return cachedToken;
|
|
103
|
+
}
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
try {
|
|
103
|
-
// 1.
|
|
107
|
+
// 1. Federated Flow (WIF) - For Cloud Run/GKE only
|
|
108
|
+
if (msAuthType === 'federated') {
|
|
109
|
+
const isGcp = await isGcpEnv();
|
|
110
|
+
if (isGcp) {
|
|
111
|
+
try {
|
|
112
|
+
syncLog('...initiating MS Graph Federated Identity (WIF) exchange (GCP)');
|
|
113
|
+
const audience = clientId || 'api://AzureADTokenExchange';
|
|
114
|
+
const metadataUrl = `http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=${audience}`;
|
|
115
|
+
|
|
116
|
+
const googleTokenResponse = await got(metadataUrl, {
|
|
117
|
+
headers: { 'Metadata-Flavor': 'Google' },
|
|
118
|
+
timeout: { request: 2000 },
|
|
119
|
+
retry: { limit: 0 }
|
|
120
|
+
});
|
|
121
|
+
const googleIdToken = googleTokenResponse.body;
|
|
122
|
+
|
|
123
|
+
// Note: Personal apps registered in 'consumers' typically do not support WIF.
|
|
124
|
+
// This flow expects an App Registration in a Work/School tenant.
|
|
125
|
+
const tenant = (envTenant && envTenant !== 'common' && envTenant !== 'consumers') ? envTenant : 'organizations';
|
|
126
|
+
const msTokenUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`;
|
|
127
|
+
|
|
128
|
+
const form = {
|
|
129
|
+
client_id: clientId,
|
|
130
|
+
grant_type: 'client_credentials',
|
|
131
|
+
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
132
|
+
client_assertion: googleIdToken,
|
|
133
|
+
scope: 'https://graph.microsoft.com/.default'
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const msTokenResponse = await got.post(msTokenUrl, { form, timeout: { request: 5000 } }).json();
|
|
137
|
+
const token = msTokenResponse.access_token;
|
|
138
|
+
const expiresOn = new Date(Date.now() + (msTokenResponse.expires_in * 1000)).toISOString();
|
|
139
|
+
|
|
140
|
+
syncLog('...retrieved MS Graph token via Federated Identity (WIF)');
|
|
141
|
+
await saveTokenCache(token, expiresOn);
|
|
142
|
+
return token;
|
|
143
|
+
} catch (wifErr) {
|
|
144
|
+
syncLog(`...MS WIF exchange failed: ${wifErr.message}`);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
syncLog('...skipping Federated Identity (Local environment)');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Service Principal
|
|
104
152
|
if (clientId && clientSecret) {
|
|
105
|
-
const tenantId = envTenant === 'common' || envTenant === 'consumers' ? 'organizations' : envTenant;
|
|
153
|
+
const tenantId = (envTenant === 'common' || envTenant === 'consumers') ? 'organizations' : envTenant;
|
|
106
154
|
const credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
|
|
107
155
|
const tokenResponse = await credential.getToken(msScopes);
|
|
108
156
|
syncLog('...retrieved MS Graph token via Client Secret (Service Principal)');
|
|
@@ -110,64 +158,62 @@ export async function getMsGraphToken(scopes = ['User.Read']) {
|
|
|
110
158
|
return tokenResponse.token;
|
|
111
159
|
}
|
|
112
160
|
|
|
113
|
-
//
|
|
161
|
+
// 3. Azure CLI Direct Exec (Silent) - Primary Local "Keyless" Path
|
|
114
162
|
const isAuthFlow = process.env.GF_AUTH_FLOW === 'true';
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (!isAuthFlow) {
|
|
119
|
-
// Revert to strictly tenant-aware fallback to avoid picking up business/EXT sessions.
|
|
120
|
-
const tenantArg = targetTenant ? `--tenant "${targetTenant}" ` : '';
|
|
121
|
-
const clientArg = clientId ? `--client-id "${clientId}" ` : '';
|
|
163
|
+
if (!isAuthFlow && !clientId) {
|
|
164
|
+
const tenantArg = (envTenant && envTenant !== 'common' && envTenant !== 'consumers') ? `--tenant "${envTenant}" ` : '';
|
|
122
165
|
|
|
123
166
|
try {
|
|
124
|
-
|
|
125
|
-
const cmd = `az account get-access-token --resource-type ms-graph ${tenantArg}${clientArg}--scope "https://graph.microsoft.com/.default" --output json`;
|
|
167
|
+
const cmd = `az account get-access-token --resource-type ms-graph ${tenantArg}--output json`;
|
|
126
168
|
const stdout = execSync(cmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], shell: true });
|
|
127
169
|
const res = JSON.parse(stdout);
|
|
128
170
|
const token = res.accessToken;
|
|
129
171
|
if (token && (token.match(/\./g) || []).length >= 2) {
|
|
130
172
|
syncLog('...retrieved valid MS Graph token via Azure CLI (silent)');
|
|
131
|
-
// az returns expiresOn in a format new Date() can parse
|
|
132
173
|
await saveTokenCache(token, res.expiresOn);
|
|
133
174
|
return token;
|
|
134
175
|
}
|
|
135
176
|
} catch (e) {
|
|
136
|
-
|
|
137
|
-
// Strategy 2: First-party + Tenant (Magic bullet for SPO license fix)
|
|
138
|
-
const fallbackCmd = `az account get-access-token --resource-type ms-graph ${tenantArg}--scope "https://graph.microsoft.com/.default" --output json`;
|
|
139
|
-
const stdout = execSync(fallbackCmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], shell: true });
|
|
140
|
-
const res = JSON.parse(stdout);
|
|
141
|
-
const token = res.accessToken;
|
|
142
|
-
if (token && (token.match(/\./g) || []).length >= 2) {
|
|
143
|
-
syncLog(`...retrieved valid MS Graph token via Azure CLI (first-party ${targetTenant} fallback)`);
|
|
144
|
-
await saveTokenCache(token, res.expiresOn);
|
|
145
|
-
return token;
|
|
146
|
-
}
|
|
147
|
-
} catch (ee) {
|
|
148
|
-
syncLog(`...silent CLI fallback failed: ${ee.message}`);
|
|
149
|
-
}
|
|
177
|
+
syncLog(`...silent CLI fallback failed: ${e.message}`);
|
|
150
178
|
}
|
|
151
179
|
}
|
|
152
180
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
// If we're not in the dedicated auth flow, we still attempt interactive fallback
|
|
156
|
-
// if silent login failed, as requested by the user for "asynchronous" operations in the worker.
|
|
157
|
-
if (!isAuthFlow) {
|
|
181
|
+
// 4. Interactive Fallback
|
|
182
|
+
if (!isAuthFlow && !clientId) {
|
|
158
183
|
console.log(`...silent CLI login failed. Falling back to interactive SDK login in the worker...`);
|
|
184
|
+
} else if (!isAuthFlow && clientId) {
|
|
185
|
+
console.log(`...using custom MS_GRAPH_CLIENT_ID. Bypassing Azure CLI for Interactive SDK login...`);
|
|
159
186
|
}
|
|
160
187
|
|
|
161
|
-
//
|
|
162
|
-
|
|
188
|
+
// Try silent refresh first, then interactive if required
|
|
189
|
+
let promptBehavior = isAuthFlow ? 'select_account' : 'none';
|
|
190
|
+
const credentialSilent = new InteractiveBrowserCredential({
|
|
163
191
|
tenantId: envTenant === 'common' ? 'consumers' : envTenant,
|
|
164
192
|
clientId,
|
|
165
|
-
prompt:
|
|
193
|
+
prompt: promptBehavior
|
|
166
194
|
});
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const tokenResponse = await credentialSilent.getToken(msScopes);
|
|
198
|
+
syncLog(`...retrieved MS Graph token via interactive login (prompt: ${promptBehavior})`);
|
|
199
|
+
await saveTokenCache(tokenResponse.token, tokenResponse.expiresOnTimestamp);
|
|
200
|
+
return tokenResponse.token;
|
|
201
|
+
} catch (silentErr) {
|
|
202
|
+
if (promptBehavior === 'none') {
|
|
203
|
+
console.log(`...silent refresh failed, prompting user...`);
|
|
204
|
+
const credentialInteractive = new InteractiveBrowserCredential({
|
|
205
|
+
tenantId: envTenant === 'common' ? 'consumers' : envTenant,
|
|
206
|
+
clientId,
|
|
207
|
+
prompt: 'select_account consent'
|
|
208
|
+
});
|
|
209
|
+
const tokenResponse = await credentialInteractive.getToken(msScopes);
|
|
210
|
+
syncLog('...retrieved MS Graph token via interactive login');
|
|
211
|
+
await saveTokenCache(tokenResponse.token, tokenResponse.expiresOnTimestamp);
|
|
212
|
+
return tokenResponse.token;
|
|
213
|
+
} else {
|
|
214
|
+
throw silentErr;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
171
217
|
|
|
172
218
|
} catch (err) {
|
|
173
219
|
throw new Error(`MS Graph Auth failed. Error: ${err.message}`);
|
|
@@ -175,6 +221,5 @@ export async function getMsGraphToken(scopes = ['User.Read']) {
|
|
|
175
221
|
}
|
|
176
222
|
|
|
177
223
|
function syncLog(msg) {
|
|
178
|
-
// Use worker sync log if available, otherwise console
|
|
179
224
|
console.log(msg);
|
|
180
225
|
}
|
|
@@ -205,7 +205,8 @@ export class OneDrive {
|
|
|
205
205
|
|
|
206
206
|
const response = await this._request('PATCH', `${this.userPath}/drive/items/${fileId}`, {
|
|
207
207
|
json: {
|
|
208
|
-
parentReference: { id: destId }
|
|
208
|
+
parentReference: { id: destId },
|
|
209
|
+
"@microsoft.graph.conflictBehavior": "replace"
|
|
209
210
|
}
|
|
210
211
|
});
|
|
211
212
|
return this.translateFile(response.body);
|
package/src/support/syncit.js
CHANGED
|
@@ -324,8 +324,9 @@ export const fxInit = ({
|
|
|
324
324
|
const currentPlatform = Auth.getPlatform();
|
|
325
325
|
if (currentPlatform === 'google' || !currentPlatform) {
|
|
326
326
|
const initialPlatforms = platformAuth || global.ScriptApp?.__platformAuth || ['google'];
|
|
327
|
-
|
|
328
|
-
|
|
327
|
+
// Prefer google if available in authorized platforms, otherwise use the first available.
|
|
328
|
+
const defaultPlatform = initialPlatforms.includes('google') ? 'google' : initialPlatforms[0];
|
|
329
|
+
Auth.setPlatform(defaultPlatform);
|
|
329
330
|
}
|
|
330
331
|
|
|
331
332
|
return synced;
|