mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.1

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.
Files changed (114) hide show
  1. package/README.md +8 -4
  2. package/dist/accounts.d.ts +44 -5
  3. package/dist/accounts.js +267 -25
  4. package/dist/auth.d.ts +11 -4
  5. package/dist/auth.js +85 -78
  6. package/dist/client-config.d.ts +90 -0
  7. package/dist/client-config.js +282 -0
  8. package/dist/client.js +10 -4
  9. package/dist/config-file.d.ts +38 -0
  10. package/dist/config-file.js +117 -0
  11. package/dist/discover.js +57 -3
  12. package/dist/doctor.d.ts +86 -0
  13. package/dist/doctor.js +384 -0
  14. package/dist/env-load.d.ts +11 -0
  15. package/dist/env-load.js +52 -0
  16. package/dist/executor.d.ts +2 -0
  17. package/dist/executor.js +2 -1
  18. package/dist/fanout.js +8 -5
  19. package/dist/fs-atomic.d.ts +3 -0
  20. package/dist/fs-atomic.js +123 -0
  21. package/dist/http-config.d.ts +35 -0
  22. package/dist/http-config.js +113 -0
  23. package/dist/http-transport.d.ts +59 -0
  24. package/dist/http-transport.js +269 -0
  25. package/dist/identity.d.ts +24 -0
  26. package/dist/identity.js +19 -0
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +197 -18
  29. package/dist/master-key.d.ts +53 -0
  30. package/dist/master-key.js +242 -0
  31. package/dist/mcp-token.d.ts +81 -0
  32. package/dist/mcp-token.js +176 -0
  33. package/dist/migrate-config.d.ts +7 -0
  34. package/dist/migrate-config.js +103 -0
  35. package/dist/migrate-tokens.js +4 -3
  36. package/dist/oauth-as.d.ts +52 -0
  37. package/dist/oauth-as.js +457 -0
  38. package/dist/oauth-consent.d.ts +28 -0
  39. package/dist/oauth-consent.js +109 -0
  40. package/dist/open-url.d.ts +1 -0
  41. package/dist/open-url.js +30 -0
  42. package/dist/reauth-hint.d.ts +2 -0
  43. package/dist/reauth-hint.js +15 -0
  44. package/dist/registry-transfer.d.ts +41 -0
  45. package/dist/registry-transfer.js +219 -0
  46. package/dist/registry.d.ts +27 -1
  47. package/dist/registry.js +117 -6
  48. package/dist/scope-catalog.d.ts +19 -0
  49. package/dist/scope-catalog.js +182 -0
  50. package/dist/scope-observability.d.ts +78 -0
  51. package/dist/scope-observability.js +192 -0
  52. package/dist/services.js +1 -1
  53. package/dist/setup-prompt.d.ts +9 -0
  54. package/dist/setup-prompt.js +59 -0
  55. package/dist/ssrf-guard.d.ts +48 -0
  56. package/dist/ssrf-guard.js +242 -0
  57. package/dist/token-store.js +23 -125
  58. package/dist/tools/_errors.d.ts +8 -2
  59. package/dist/tools/_errors.js +62 -6
  60. package/dist/tools/account-wizard.d.ts +33 -0
  61. package/dist/tools/account-wizard.js +284 -0
  62. package/dist/tools/accounts-tool.d.ts +8 -5
  63. package/dist/tools/accounts-tool.js +38 -15
  64. package/dist/tools/admin.js +2 -2
  65. package/dist/tools/calendar.js +2 -2
  66. package/dist/tools/chat.js +2 -2
  67. package/dist/tools/contacts.d.ts +40 -0
  68. package/dist/tools/contacts.js +135 -2
  69. package/dist/tools/docs.d.ts +3 -0
  70. package/dist/tools/docs.js +60 -9
  71. package/dist/tools/drive.d.ts +3 -0
  72. package/dist/tools/drive.js +60 -8
  73. package/dist/tools/forms.js +4 -2
  74. package/dist/tools/generated/_shared.d.ts +1 -3
  75. package/dist/tools/generated/_shared.js +3 -3
  76. package/dist/tools/generated/admin.js +175 -132
  77. package/dist/tools/generated/appsmarket.js +6 -2
  78. package/dist/tools/generated/calendar.js +45 -27
  79. package/dist/tools/generated/chat.js +81 -47
  80. package/dist/tools/generated/classroom.js +131 -104
  81. package/dist/tools/generated/cloudidentity.js +73 -61
  82. package/dist/tools/generated/cloudsearch.js +59 -49
  83. package/dist/tools/generated/contacts.js +22 -14
  84. package/dist/tools/generated/drive.js +51 -35
  85. package/dist/tools/generated/driveactivity.js +5 -1
  86. package/dist/tools/generated/drivelabels.js +30 -25
  87. package/dist/tools/generated/forms.js +7 -3
  88. package/dist/tools/generated/gmail.js +73 -58
  89. package/dist/tools/generated/groupsmigration.js +5 -1
  90. package/dist/tools/generated/groupssettings.js +7 -3
  91. package/dist/tools/generated/keep.js +12 -7
  92. package/dist/tools/generated/licensing.js +11 -7
  93. package/dist/tools/generated/meet.js +20 -13
  94. package/dist/tools/generated/postmaster.js +8 -4
  95. package/dist/tools/generated/reseller.js +22 -17
  96. package/dist/tools/generated/script.js +26 -16
  97. package/dist/tools/generated/sheets.js +11 -7
  98. package/dist/tools/generated/tasks.js +6 -2
  99. package/dist/tools/generated/vault.js +38 -33
  100. package/dist/tools/generated/workspaceevents.js +12 -7
  101. package/dist/tools/gmail-mime.d.ts +38 -8
  102. package/dist/tools/gmail-mime.js +75 -71
  103. package/dist/tools/gmail.d.ts +31 -0
  104. package/dist/tools/gmail.js +483 -99
  105. package/dist/tools/google-api.js +7 -4
  106. package/dist/tools/meet.js +2 -2
  107. package/dist/tools/searchconsole.js +2 -2
  108. package/dist/tools/sheets.js +6 -2
  109. package/dist/tools/slides.js +4 -2
  110. package/dist/tools/tasks.js +2 -2
  111. package/dist/types.d.ts +1 -1
  112. package/dist/write-control.d.ts +24 -1
  113. package/dist/write-control.js +26 -1
  114. package/package.json +26 -7
package/README.md CHANGED
@@ -4,15 +4,17 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/mcp-google-multi?label=npm&color=cb3837)](https://www.npmjs.com/package/mcp-google-multi)
6
6
 
7
- - 🧰 **Exhaustive** — 872 tools across 28 services + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
7
+ - 🧰 **Exhaustive** — 874 tools across 28 services + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
8
8
  - 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
9
9
  - 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
10
+ - 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI) → [remote setup](./docs/http-setup.md)
11
+ - ✉️ **Built for real work** — send and read email in Markdown with attachments and one-call replies, an interactive setup wizard with a `doctor` self-check, and per-account scope profiles → [features tour](./docs/features.md)
10
12
 
11
13
  ## Quick setup
12
14
 
13
15
  You don't need to know anything about MCP or OAuth — five steps, all copy-paste:
14
16
 
15
- 1. **Install [Node.js](https://nodejs.org) 20 or newer**, then install the server:
17
+ 1. **Install [Node.js](https://nodejs.org) 22 or newer**, then install the server:
16
18
 
17
19
  ```bash
18
20
  npm install -g mcp-google-multi
@@ -46,7 +48,9 @@ You don't need to know anything about MCP or OAuth — five steps, all copy-past
46
48
 
47
49
  Restart your client and the tools appear. Check everything with `mcp-google-multi config check`.
48
50
 
49
- **Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Secrets in a vault](./docs/secrets.md) · [Upgrading from v4](./docs/upgrading-v4.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
51
+ **Running it remotely?** To reach the server from [claude.ai](https://claude.ai) as a custom connector or from another machine, run it over HTTP — it ships its own OAuth 2.1 server, so no bearer tokens to paste. Follow [Remote HTTP setup](./docs/http-setup.md) (Cloudflare named tunnel, Docker, or one-click Render/Railway). Coming from v5? See the [v6 migration guide](./MIGRATION-v6.md).
52
+
53
+ **Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Remote / HTTP setup](./docs/http-setup.md) · [Secrets in a vault](./docs/secrets.md) · [Migrating to v6](./MIGRATION-v6.md) · [Upgrading from v4](./docs/upgrading-v4.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
50
54
 
51
55
  ## Maintainer & credits
52
56
 
@@ -54,7 +58,7 @@ Built and maintained by **Abdelbaki Berkati** — [berkati.xyz](https://berkati.
54
58
 
55
59
  Development is **funded by [IdeaCrafters](https://ideacrafters.com)** ([@IdeaCraftersHQ](https://github.com/IdeaCraftersHQ)) — the studio that pays for this OSS to exist.
56
60
 
57
- Thanks to contributors [@obatried](https://github.com/obatried), [@trevor-commits](https://github.com/trevor-commits), and [@mjreddy](https://github.com/mjreddy). The project is maintainer-led (roadmap on [Milestones](https://github.com/bakissation/mcp-google-multi/milestones); bug reports welcome, feature PRs by prior agreement — see [CONTRIBUTING.md](./CONTRIBUTING.md)). **v5 is complete and in a feedback period: [open an issue](https://github.com/bakissation/mcp-google-multi/issues/new/choose) with bugs, pain points, or what you wish it did — it directly shapes the v6 roadmap.** Security reports go to [SECURITY.md](./SECURITY.md), never a public issue.
61
+ Thanks to contributors [@obatried](https://github.com/obatried), [@trevor-commits](https://github.com/trevor-commits), and [@mjreddy](https://github.com/mjreddy). The project is maintainer-led (roadmap on [Milestones](https://github.com/bakissation/mcp-google-multi/milestones); bug reports welcome, feature PRs by prior agreement — see [CONTRIBUTING.md](./CONTRIBUTING.md)). **Feedback shapes the roadmap: [open an issue](https://github.com/bakissation/mcp-google-multi/issues/new/choose) with bugs, pain points, or what you wish it did.** Security reports go to [SECURITY.md](./SECURITY.md), never a public issue.
58
62
 
59
63
  ## License
60
64
 
@@ -1,11 +1,50 @@
1
+ import { z } from 'zod';
2
+ import type { ScopeProfile } from './scope-catalog.js';
3
+ export declare function getTokenDir(): string;
1
4
  export interface AccountConfig {
2
5
  email: string;
3
6
  tokenPath: string;
4
7
  encPath: string;
8
+ scopeProfile?: string;
9
+ admin?: boolean;
10
+ source: 'config' | 'env';
5
11
  }
6
- /** Tuple of account aliases (at least one) — usable with z.enum() */
7
- export declare const ACCOUNTS: [string, ...string[]];
8
- /** Map of alias → { email, tokenPath } */
9
- export declare const ACCOUNT_CONFIG: Record<string, AccountConfig>;
10
- /** Valid account alias (string union isn't static, so tools use z.enum(ACCOUNTS)) */
12
+ export interface AccountSet {
13
+ aliases: string[];
14
+ configs: Record<string, AccountConfig>;
15
+ scopeProfiles: Record<string, ScopeProfile>;
16
+ source: 'env' | 'file' | 'merged';
17
+ stamp: string;
18
+ defaultAccount?: string;
19
+ defaultAccountSource?: 'env' | 'config';
20
+ }
21
+ export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw'): AccountSet;
22
+ /** Live accessor: dispatch-time readers use this, never a captured snapshot. */
23
+ export declare function getAccountSet(): AccountSet;
24
+ /** Re-resolve after a config.json mutation and swap the live set. */
25
+ export declare function invalidateAccountSet(): AccountSet;
26
+ export declare function refreshAccountSetIfStale(): void;
27
+ /** Cross-process staleness probe (BR-7): one stat, compared against the stamp. */
28
+ export declare function isAccountSetStale(): boolean;
29
+ /** Account aliases (possibly empty on a fresh install).
30
+ * Snapshot from the initial load; enums widen only when the registry is
31
+ * rebuilt after a mutation (account_add, later slice). */
32
+ export declare const ACCOUNTS: string[];
33
+ /**
34
+ * The `account` param schema, empty-registry-safe. A `z.enum` requires at least
35
+ * one value, so a fresh install (zero aliases) would throw at schema-build time
36
+ * (module load) and take down every CLI — including `doctor`, which is meant to
37
+ * REPORT the empty registry (gap #23). Fall back to a plain string when there
38
+ * are no aliases: no alias exists to enumerate, the server refuses to boot empty
39
+ * anyway, and dispatch validates the account against the live set. */
40
+ export declare function accountAliasSchemaFor(aliases: readonly string[]): z.ZodType<string>;
41
+ /** Shared, load-time snapshot used by every tool's `account` field. */
42
+ export declare const accountAliasSchema: z.ZodType<string>;
43
+ /**
44
+ * BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
45
+ * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
46
+ * bootstrap and diagnostic CLIs return before this guard, so it gates only the
47
+ * server path (called from index.ts after the CLI branches). */
48
+ export declare function assertServerAccountsConfigured(): void;
49
+ /** Valid account alias (string union isn't static, so tools use accountAliasSchema) */
11
50
  export type Account = string;
package/dist/accounts.js CHANGED
@@ -1,23 +1,28 @@
1
- import dotenv from 'dotenv';
2
- import { fileURLToPath } from 'node:url';
3
1
  import path from 'node:path';
4
- import { homedir } from 'node:os';
5
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
- // dotenv v17 prints a banner to stdout, corrupting the stdio JSON-RPC channel;
7
- // DOTENV_CONFIG_QUIET cannot help, it would be read from .env after config() ran.
8
- dotenv.config({ quiet: true });
9
- dotenv.config({ path: path.resolve(__dirname, '..', '.env'), quiet: true });
10
- const defaultTokenDir = path.join(process.env.XDG_CONFIG_HOME || path.join(homedir(), '.config'), 'mcp-google-multi', 'tokens');
2
+ import fs from 'node:fs';
3
+ import { z } from 'zod';
4
+ import { loadEnvFiles } from './env-load.js';
5
+ import { CONFIG_VERSION, configDir, configFilePath, failStartup, loadConfigFile, mutateConfigFile } from './config-file.js';
6
+ import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
7
+ const envLoad = loadEnvFiles();
8
+ const defaultTokenDir = path.join(configDir(), 'tokens');
11
9
  const tokenDir = process.env.TOKEN_STORE_PATH
12
10
  ? path.resolve(process.env.TOKEN_STORE_PATH)
13
11
  : defaultTokenDir;
14
- /** Format: GOOGLE_ACCOUNTS="alias1:email1,alias2:email2". */
15
- function parseAccounts() {
16
- const raw = process.env.GOOGLE_ACCOUNTS;
17
- if (!raw || raw.trim() === '') {
18
- throw new Error('GOOGLE_ACCOUNTS is not set. Define it in .env like:\n' +
19
- 'GOOGLE_ACCOUNTS=work:user@company.com,personal:user@gmail.com');
20
- }
12
+ export function getTokenDir() {
13
+ return tokenDir;
14
+ }
15
+ function parseCsv(value) {
16
+ return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
17
+ }
18
+ function accountPaths(alias) {
19
+ return {
20
+ tokenPath: path.join(tokenDir, alias, 'token.json'),
21
+ encPath: path.join(tokenDir, `${alias}.enc`),
22
+ };
23
+ }
24
+ /** v5 env parser, guards verbatim. Format: GOOGLE_ACCOUNTS="alias1:email1,alias2:email2". */
25
+ function parseEnvAccounts(raw, adminAliases) {
21
26
  const configs = {};
22
27
  const aliases = [];
23
28
  for (const entry of raw.split(',')) {
@@ -35,7 +40,7 @@ function parseAccounts() {
35
40
  }
36
41
  // Restrict alias to a safe charset so it can't escape `tokenDir` via path traversal
37
42
  // (e.g. "../../etc/passwd:foo@bar.com" in .env).
38
- if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
43
+ if (!/^[a-zA-Z0-9_-]+$/.test(alias) || ['__proto__', 'constructor', 'prototype'].includes(alias)) {
39
44
  throw new Error(`Invalid alias "${alias}". Allowed characters: letters, digits, underscore, hyphen.`);
40
45
  }
41
46
  if (aliases.includes(alias)) {
@@ -44,17 +49,254 @@ function parseAccounts() {
44
49
  aliases.push(alias);
45
50
  configs[alias] = {
46
51
  email,
47
- tokenPath: path.join(tokenDir, alias, 'token.json'),
48
- encPath: path.join(tokenDir, `${alias}.enc`),
52
+ ...accountPaths(alias),
53
+ admin: adminAliases.includes(alias) || undefined,
54
+ source: 'env',
49
55
  };
50
56
  }
51
57
  if (aliases.length === 0) {
52
58
  throw new Error('GOOGLE_ACCOUNTS must define at least one account.');
53
59
  }
54
- return { aliases: aliases, configs };
60
+ return { aliases, configs };
61
+ }
62
+ function noAccountsMessage() {
63
+ const envHint = envLoad.loaded.length === 0
64
+ ? ` No readable .env file was found (searched: ${envLoad.searched.join(', ')}).`
65
+ : '';
66
+ return (`no accounts configured. Add them to ${configFilePath()} (run: mcp-google-multi migrate-config), ` +
67
+ `or set GOOGLE_ACCOUNTS=work:user@company.com,personal:user@gmail.com in the environment.${envHint}`);
68
+ }
69
+ /**
70
+ * Registry resolution (BR-2): a non-empty GOOGLE_ACCOUNTS env takes the WHOLE
71
+ * registry from env (12-factor override, never merged key-by-key); otherwise
72
+ * the registry comes from config.json. GOOGLE_ADMIN_ACCOUNTS, when NON-EMPTY,
73
+ * overrides per-account admin flags from the file (env var > config field);
74
+ * empty behaves as unset, mirroring GOOGLE_ACCOUNTS semantics.
75
+ */
76
+ let warnedLegacy = false;
77
+ export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit') {
78
+ const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
79
+ const rawEnv = env.GOOGLE_ACCOUNTS;
80
+ const fail = (slug, message) => {
81
+ if (onInvalid === 'throw')
82
+ throw new Error(`${slug}: ${message}`);
83
+ failStartup(slug, message);
84
+ };
85
+ // Validate the legacy global override AT BOOT, whatever GOOGLE_TOOLSETS
86
+ // selects — otherwise a typo only surfaces at dispatch time inside
87
+ // account_list instead of the promised startup error (BR3).
88
+ const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
89
+ if (legacyNames.length > 0) {
90
+ for (const bundle of resolveBundleAliases(legacyNames)) {
91
+ if (bundle === 'admin') {
92
+ fail('E_UNKNOWN_BUNDLE', '"admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
93
+ }
94
+ if (!(bundle in BUNDLE_CATALOG)) {
95
+ const hint = closestBundle(bundle);
96
+ fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
97
+ }
98
+ }
99
+ if (!warnedLegacy) {
100
+ warnedLegacy = true;
101
+ process.stderr.write('E_LEGACY_GLOBAL_SCOPES: GOOGLE_OPTIONAL_SCOPES applies one global scope set to every account; migrate to per-account scopeProfiles (mcp-google-multi migrate-config).\n');
102
+ }
103
+ }
104
+ if (rawEnv && rawEnv.trim() !== '') {
105
+ const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv);
106
+ materializeFirstRun(aliases, configs, filePath);
107
+ const def = resolveDefaultAccount(env, null, aliases, fail);
108
+ return {
109
+ aliases,
110
+ configs,
111
+ // Env-sourced accounts cannot reference file profiles; the legacy
112
+ // GOOGLE_OPTIONAL_SCOPES override is applied live in auth.ts.
113
+ scopeProfiles: { base: { bundles: [] } },
114
+ source: 'env',
115
+ stamp: 'env:0',
116
+ ...def,
117
+ };
118
+ }
119
+ // Stat BEFORE read: a cross-process write landing between the two makes the
120
+ // stamp conservative (flags stale again next dispatch) instead of pinning
121
+ // stale content behind a fresh stamp.
122
+ const preStamp = fileStamp(filePath, CONFIG_VERSION);
123
+ const config = loadConfigFile(filePath, onInvalid);
124
+ const entries = Object.entries(config?.accounts ?? {});
125
+ if (entries.length === 0) {
126
+ // Gap #23: an empty registry is NOT fatal at resolve time — the bootstrap and
127
+ // diagnostic CLIs (doctor/reset/account import/migrate-config/config check)
128
+ // must run on a fresh install. Only the stdio/http SERVER refuses to boot
129
+ // empty (BR-4), enforced by assertServerAccountsConfigured() in index.ts.
130
+ // The dispatch-path reload ('throw') still throws so a mid-session emptied
131
+ // config.json keeps the last-good registry (BR-7) instead of dropping tools.
132
+ if (onInvalid === 'throw') {
133
+ throw new Error(`E_NO_ACCOUNTS_CONFIGURED: ${noAccountsMessage()}`);
134
+ }
135
+ return {
136
+ aliases: [],
137
+ configs: {},
138
+ scopeProfiles: { base: { bundles: [] } },
139
+ source: 'file',
140
+ stamp: `${config?.version ?? CONFIG_VERSION}:${preStamp.split(':')[1]}`,
141
+ };
142
+ }
143
+ // BR3: unknown bundle names fail loudly (a mis-scoped token is worse than a
144
+ // clear error); v5 silently filtered them. Null prototype: profile names are
145
+ // user input and must never collide with Object.prototype members.
146
+ const scopeProfiles = Object.create(null);
147
+ scopeProfiles.base = { bundles: [] };
148
+ for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
149
+ const bundles = resolveBundleAliases(profile.bundles);
150
+ for (const bundle of bundles) {
151
+ if (!(bundle in BUNDLE_CATALOG)) {
152
+ const hint = closestBundle(bundle);
153
+ fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in scope profile "${name}"${hint ? ` — did you mean "${hint}"?` : ''}`);
154
+ }
155
+ }
156
+ if (profile.includesBase === false && bundles.length === 0 && profile.admin !== true) {
157
+ fail('E_CONFIG_INVALID', `scope profile "${name}" resolves to zero scopes (includesBase: false with no bundles); Google rejects an empty consent request.`);
158
+ }
159
+ scopeProfiles[name] = { ...profile, bundles };
160
+ }
161
+ const configs = {};
162
+ const aliases = [];
163
+ for (const [alias, entry] of entries) {
164
+ if (entry.scopeProfile && !Object.hasOwn(scopeProfiles, entry.scopeProfile)) {
165
+ fail('E_CONFIG_INVALID', `account "${alias}" references scope profile "${entry.scopeProfile}", which is not defined in scopeProfiles.`);
166
+ }
167
+ aliases.push(alias);
168
+ configs[alias] = {
169
+ email: entry.email,
170
+ ...accountPaths(alias),
171
+ scopeProfile: entry.scopeProfile,
172
+ admin: adminEnv.length > 0 ? adminEnv.includes(alias) : entry.admin,
173
+ source: 'config',
174
+ };
175
+ }
176
+ const def = resolveDefaultAccount(env, config?.defaultAccount ?? null, aliases, fail);
177
+ return {
178
+ aliases,
179
+ configs,
180
+ scopeProfiles,
181
+ source: 'file',
182
+ stamp: `${config?.version ?? CONFIG_VERSION}:${preStamp.split(':')[1]}`,
183
+ ...def,
184
+ };
185
+ }
186
+ /** A2: env GOOGLE_DEFAULT_ACCOUNT > config.defaultAccount > unset. A configured
187
+ * default naming an unknown alias refuses to start (E_DEFAULT_ACCOUNT_UNKNOWN —
188
+ * deliberately NOT E_CONFIG_INVALID: the config is schema-valid). */
189
+ function resolveDefaultAccount(env, fromConfig, aliases, fail) {
190
+ const fromEnv = env.GOOGLE_DEFAULT_ACCOUNT?.trim();
191
+ const value = fromEnv || fromConfig || undefined;
192
+ if (!value)
193
+ return {};
194
+ if (!aliases.includes(value)) {
195
+ fail('E_DEFAULT_ACCOUNT_UNKNOWN', `default account "${value}" (from ${fromEnv ? 'GOOGLE_DEFAULT_ACCOUNT' : 'config.json defaultAccount'}) is not a configured alias. Valid: ${aliases.join(', ')}.`);
196
+ }
197
+ return { defaultAccount: value, defaultAccountSource: fromEnv ? 'env' : 'config' };
198
+ }
199
+ function fileStamp(filePath, version) {
200
+ try {
201
+ return `${version}:${fs.statSync(filePath).mtimeMs}`;
202
+ }
203
+ catch {
204
+ return `${version}:0`;
205
+ }
206
+ }
207
+ // First-run shim (BC6): env is set and no config.json exists yet — materialize
208
+ // the file so the wizard has something to edit. Env still wins this session;
209
+ // a write failure must never block boot (warn on stderr and continue).
210
+ function materializeFirstRun(aliases, configs, filePath) {
211
+ if (fs.existsSync(filePath))
212
+ return;
213
+ try {
214
+ let wrote = false;
215
+ // mutateConfigFile = lock + re-check + atomic write, so a concurrent
216
+ // wizard/migrate writer is never clobbered (the loaded `current` is
217
+ // re-read under the lock; only a still-absent file gets the env content).
218
+ mutateConfigFile((current) => {
219
+ if (Object.keys(current.accounts ?? {}).length > 0)
220
+ return current;
221
+ const accounts = {};
222
+ for (const alias of aliases) {
223
+ accounts[alias] = {
224
+ email: configs[alias].email,
225
+ ...(configs[alias].admin ? { admin: true } : {}),
226
+ };
227
+ }
228
+ wrote = true;
229
+ return { ...current, version: current.version || CONFIG_VERSION, accounts };
230
+ }, filePath);
231
+ if (wrote) {
232
+ process.stderr.write(`Materialized ${filePath} from GOOGLE_ACCOUNTS (env still overrides while set).\n`);
233
+ }
234
+ }
235
+ catch (e) {
236
+ process.stderr.write(`Could not materialize ${filePath}: ${e.message}\n`);
237
+ }
238
+ }
239
+ let current = resolveAccounts();
240
+ /** Live accessor: dispatch-time readers use this, never a captured snapshot. */
241
+ export function getAccountSet() {
242
+ return current;
243
+ }
244
+ /** Re-resolve after a config.json mutation and swap the live set. */
245
+ export function invalidateAccountSet() {
246
+ current = resolveAccounts();
247
+ return current;
248
+ }
249
+ /**
250
+ * Dispatch-path reload (BR-7): NEVER exits and never throws — a mid-edit,
251
+ * corrupt, or deleted config.json keeps the last-good set and warns once per
252
+ * distinct failure on stderr. failStartup semantics are boot/CLI-only.
253
+ */
254
+ let lastReloadWarning = '';
255
+ export function refreshAccountSetIfStale() {
256
+ if (!isAccountSetStale())
257
+ return;
258
+ try {
259
+ current = resolveAccounts(process.env, configFilePath(), 'throw');
260
+ lastReloadWarning = '';
261
+ }
262
+ catch (e) {
263
+ const msg = e.message;
264
+ if (msg !== lastReloadWarning) {
265
+ process.stderr.write(`config.json reload skipped (keeping last-good registry): ${msg}\n`);
266
+ lastReloadWarning = msg;
267
+ }
268
+ }
269
+ }
270
+ /** Cross-process staleness probe (BR-7): one stat, compared against the stamp. */
271
+ export function isAccountSetStale() {
272
+ if (current.source !== 'file')
273
+ return false;
274
+ const [version] = current.stamp.split(':');
275
+ return current.stamp !== fileStamp(configFilePath(), Number(version));
276
+ }
277
+ /** Account aliases (possibly empty on a fresh install).
278
+ * Snapshot from the initial load; enums widen only when the registry is
279
+ * rebuilt after a mutation (account_add, later slice). */
280
+ export const ACCOUNTS = current.aliases;
281
+ /**
282
+ * The `account` param schema, empty-registry-safe. A `z.enum` requires at least
283
+ * one value, so a fresh install (zero aliases) would throw at schema-build time
284
+ * (module load) and take down every CLI — including `doctor`, which is meant to
285
+ * REPORT the empty registry (gap #23). Fall back to a plain string when there
286
+ * are no aliases: no alias exists to enumerate, the server refuses to boot empty
287
+ * anyway, and dispatch validates the account against the live set. */
288
+ export function accountAliasSchemaFor(aliases) {
289
+ return aliases.length > 0 ? z.enum(aliases) : z.string();
290
+ }
291
+ /** Shared, load-time snapshot used by every tool's `account` field. */
292
+ export const accountAliasSchema = accountAliasSchemaFor(ACCOUNTS);
293
+ /**
294
+ * BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
295
+ * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
296
+ * bootstrap and diagnostic CLIs return before this guard, so it gates only the
297
+ * server path (called from index.ts after the CLI branches). */
298
+ export function assertServerAccountsConfigured() {
299
+ if (current.aliases.length === 0) {
300
+ failStartup('E_NO_ACCOUNTS_CONFIGURED', noAccountsMessage());
301
+ }
55
302
  }
56
- const parsed = parseAccounts();
57
- /** Tuple of account aliases (at least one) — usable with z.enum() */
58
- export const ACCOUNTS = parsed.aliases;
59
- /** Map of alias → { email, tokenPath } */
60
- export const ACCOUNT_CONFIG = parsed.configs;
package/dist/auth.d.ts CHANGED
@@ -1,10 +1,17 @@
1
+ import { ADMIN_SCOPES } from './scope-catalog.js';
1
2
  export declare const BASE_SCOPES: string[];
2
3
  export declare const OPTIONAL_SCOPE_BUNDLES: Record<string, string[]>;
3
- export declare const ADMIN_SCOPES: string[];
4
- /** Bundle keys enabled via GOOGLE_OPTIONAL_SCOPES (e.g. ["forms","chat"]). */
4
+ export { ADMIN_SCOPES };
5
+ /** Union of every account's resolved bundles: a service registers if ANY
6
+ * account can authorize it; per-account authz happens at call time (BR2). */
5
7
  export declare function getOptionalBundles(): string[];
6
- /** Account aliases granted ADMIN_SCOPES via GOOGLE_ADMIN_ACCOUNTS. */
8
+ /** Aliases granted ADMIN_SCOPES: per-account admin flag (env
9
+ * GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
10
+ * scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
7
11
  export declare function getAdminAccounts(): string[];
8
- /** Scopes are fixed at consent time: changing GOOGLE_OPTIONAL_SCOPES or GOOGLE_ADMIN_ACCOUNTS requires re-running auth. */
12
+ /** Scopes are fixed at consent time: changing an account's profile (or the
13
+ * legacy env) changes its consent set and requires re-running auth. Evaluated
14
+ * per account: `work` can carry admin + gmail_settings while `personal` is
15
+ * never asked for them. */
9
16
  export declare function resolveScopesForAccount(alias: string): string[];
10
17
  export declare function runAuthFlow(args: string[]): Promise<void>;
package/dist/auth.js CHANGED
@@ -2,8 +2,10 @@ import { OAuth2Client } from 'googleapis-common';
2
2
  import http from 'node:http';
3
3
  import { URL } from 'node:url';
4
4
  import { randomBytes } from 'node:crypto';
5
- import open from 'open';
6
- import { ACCOUNTS, ACCOUNT_CONFIG } from './accounts.js';
5
+ import { openUrl } from './open-url.js';
6
+ import { ACCOUNTS, getAccountSet } from './accounts.js';
7
+ import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
8
+ import { resolveMasterKey } from './master-key.js';
7
9
  import { writeToken } from './token-store.js';
8
10
  // Personal (non-Workspace) accounts 403 on admin scopes; ADMIN_SCOPES stays per-account opt-in, never granted by default.
9
11
  export const BASE_SCOPES = [
@@ -18,67 +20,12 @@ export const BASE_SCOPES = [
18
20
  'https://www.googleapis.com/auth/tasks',
19
21
  'https://www.googleapis.com/auth/meetings.space.readonly',
20
22
  ];
21
- export const OPTIONAL_SCOPE_BUNDLES = {
22
- slides: [
23
- 'https://www.googleapis.com/auth/presentations',
24
- ],
25
- forms: [
26
- 'https://www.googleapis.com/auth/forms.body',
27
- 'https://www.googleapis.com/auth/forms.responses.readonly',
28
- ],
29
- chat: [
30
- 'https://www.googleapis.com/auth/chat.spaces',
31
- 'https://www.googleapis.com/auth/chat.messages',
32
- 'https://www.googleapis.com/auth/chat.messages.create',
33
- ],
34
- // These extend the always-on gmail service: users.settings.* writes require these
35
- // scopes (reads already work via gmail.modify); sharing is split out as riskier.
36
- gmail_settings: [
37
- 'https://www.googleapis.com/auth/gmail.settings.basic',
38
- ],
39
- gmail_settings_sharing: [
40
- 'https://www.googleapis.com/auth/gmail.settings.sharing',
41
- ],
42
- classroom: [
43
- 'https://www.googleapis.com/auth/classroom.courses',
44
- 'https://www.googleapis.com/auth/classroom.coursework.me',
45
- 'https://www.googleapis.com/auth/classroom.coursework.students',
46
- 'https://www.googleapis.com/auth/classroom.courseworkmaterials',
47
- 'https://www.googleapis.com/auth/classroom.rosters',
48
- 'https://www.googleapis.com/auth/classroom.announcements',
49
- 'https://www.googleapis.com/auth/classroom.topics',
50
- ],
51
- cloudidentity: [
52
- 'https://www.googleapis.com/auth/cloud-identity.groups',
53
- 'https://www.googleapis.com/auth/cloud-identity.devices',
54
- ],
55
- cloudsearch: ['https://www.googleapis.com/auth/cloud_search'],
56
- vault: ['https://www.googleapis.com/auth/ediscovery'],
57
- keep: ['https://www.googleapis.com/auth/keep'],
58
- driveactivity: ['https://www.googleapis.com/auth/drive.activity.readonly'],
59
- drivelabels: [
60
- 'https://www.googleapis.com/auth/drive.labels',
61
- 'https://www.googleapis.com/auth/drive.admin.labels',
62
- ],
63
- script: [
64
- 'https://www.googleapis.com/auth/script.projects',
65
- 'https://www.googleapis.com/auth/script.deployments',
66
- 'https://www.googleapis.com/auth/script.processes',
67
- 'https://www.googleapis.com/auth/script.metrics',
68
- ],
69
- postmaster: ['https://www.googleapis.com/auth/postmaster.readonly'],
70
- groupssettings: ['https://www.googleapis.com/auth/apps.groups.settings'],
71
- groupsmigration: ['https://www.googleapis.com/auth/apps.groups.migration'],
72
- licensing: ['https://www.googleapis.com/auth/apps.licensing'],
73
- reseller: ['https://www.googleapis.com/auth/apps.order'],
74
- appsmarket: ['https://www.googleapis.com/auth/appsmarketplace.license'],
75
- };
76
- export const ADMIN_SCOPES = [
77
- 'https://www.googleapis.com/auth/admin.reports.audit.readonly',
78
- 'https://www.googleapis.com/auth/admin.directory.user',
79
- 'https://www.googleapis.com/auth/admin.directory.group.readonly',
80
- 'https://www.googleapis.com/auth/admin.directory.group.member.readonly',
81
- ];
23
+ // Kept as a derived view for compat (docs generator, tests); the catalog in
24
+ // scope-catalog.ts is the source of truth. "admin" is not an optional bundle.
25
+ export const OPTIONAL_SCOPE_BUNDLES = Object.fromEntries(Object.entries(BUNDLE_CATALOG)
26
+ .filter(([name]) => name !== 'admin')
27
+ .map(([name, entry]) => [name, entry.scopes]));
28
+ export { ADMIN_SCOPES };
82
29
  /** Parse comma-separated env value into a deduplicated string array. */
83
30
  function parseCsvEnv(name) {
84
31
  return (process.env[name]?.trim() ?? '')
@@ -86,19 +33,79 @@ function parseCsvEnv(name) {
86
33
  .map(s => s.trim())
87
34
  .filter(Boolean);
88
35
  }
89
- /** Bundle keys enabled via GOOGLE_OPTIONAL_SCOPES (e.g. ["forms","chat"]). */
36
+ /**
37
+ * Legacy global override (BC7): a set GOOGLE_OPTIONAL_SCOPES acts as an
38
+ * implicit "legacy-global" profile applied to every account (env wins over
39
+ * file profiles, cc-config R2). Unknown names now fail loudly (BR3) where v5
40
+ * silently dropped them — the intended migration signal.
41
+ */
42
+ function legacyGlobalProfile() {
43
+ const names = parseCsvEnv('GOOGLE_OPTIONAL_SCOPES');
44
+ if (names.length === 0)
45
+ return null;
46
+ const bundles = resolveBundleAliases(names);
47
+ // Boot-time validation lives in resolveAccounts (runs whatever
48
+ // GOOGLE_TOOLSETS selects); this is defense-in-depth for direct callers.
49
+ for (const bundle of bundles) {
50
+ if (bundle === 'admin') {
51
+ throw new Error('E_UNKNOWN_BUNDLE: "admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
52
+ }
53
+ if (!(bundle in BUNDLE_CATALOG)) {
54
+ const hint = closestBundle(bundle);
55
+ throw new Error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
56
+ }
57
+ }
58
+ return { bundles };
59
+ }
60
+ function profileForAccount(alias) {
61
+ const legacy = legacyGlobalProfile();
62
+ if (legacy)
63
+ return legacy;
64
+ const set = getAccountSet();
65
+ const name = set.configs[alias]?.scopeProfile ?? 'base';
66
+ // hasOwn: a profile named like an Object.prototype member must never
67
+ // resolve to the inherited function.
68
+ return Object.hasOwn(set.scopeProfiles, name) ? set.scopeProfiles[name] : { bundles: [] };
69
+ }
70
+ /** Union of every account's resolved bundles: a service registers if ANY
71
+ * account can authorize it; per-account authz happens at call time (BR2). */
90
72
  export function getOptionalBundles() {
91
- return parseCsvEnv('GOOGLE_OPTIONAL_SCOPES').filter(b => b in OPTIONAL_SCOPE_BUNDLES);
73
+ const legacy = legacyGlobalProfile();
74
+ if (legacy)
75
+ return legacy.bundles.filter(b => b !== 'admin');
76
+ const union = new Set();
77
+ const set = getAccountSet();
78
+ for (const alias of set.aliases) {
79
+ for (const b of profileForAccount(alias).bundles) {
80
+ if (b !== 'admin')
81
+ union.add(b);
82
+ }
83
+ }
84
+ return [...union];
92
85
  }
93
- /** Account aliases granted ADMIN_SCOPES via GOOGLE_ADMIN_ACCOUNTS. */
86
+ /** Aliases granted ADMIN_SCOPES: per-account admin flag (env
87
+ * GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
88
+ * scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
94
89
  export function getAdminAccounts() {
95
- return parseCsvEnv('GOOGLE_ADMIN_ACCOUNTS');
90
+ const { aliases, configs } = getAccountSet();
91
+ return aliases.filter((a) => {
92
+ if (configs[a].admin === true)
93
+ return true;
94
+ const p = profileForAccount(a);
95
+ return p.admin === true || p.bundles.includes('admin');
96
+ });
96
97
  }
97
- /** Scopes are fixed at consent time: changing GOOGLE_OPTIONAL_SCOPES or GOOGLE_ADMIN_ACCOUNTS requires re-running auth. */
98
+ /** Scopes are fixed at consent time: changing an account's profile (or the
99
+ * legacy env) changes its consent set and requires re-running auth. Evaluated
100
+ * per account: `work` can carry admin + gmail_settings while `personal` is
101
+ * never asked for them. */
98
102
  export function resolveScopesForAccount(alias) {
99
- const scopes = [...BASE_SCOPES];
100
- for (const bundle of getOptionalBundles()) {
101
- scopes.push(...OPTIONAL_SCOPE_BUNDLES[bundle]);
103
+ const profile = profileForAccount(alias);
104
+ const scopes = profile.includesBase === false ? [] : [...BASE_SCOPES];
105
+ for (const bundle of profile.bundles) {
106
+ if (bundle === 'admin')
107
+ continue;
108
+ scopes.push(...BUNDLE_CATALOG[bundle].scopes);
102
109
  }
103
110
  if (getAdminAccounts().includes(alias)) {
104
111
  scopes.push(...ADMIN_SCOPES);
@@ -122,12 +129,11 @@ export async function runAuthFlow(args) {
122
129
  console.error(`Unknown account "${alias}". Valid aliases: ${ACCOUNTS.join(', ')}`);
123
130
  process.exit(1);
124
131
  }
125
- const config = ACCOUNT_CONFIG[alias];
132
+ const config = getAccountSet().configs[alias];
126
133
  const scopes = resolveScopesForAccount(alias);
127
- if (!process.env.MASTER_KEY) {
128
- console.error('MASTER_KEY is not set. Generate one (openssl rand -base64 32) and add it to .env before authenticating.');
129
- process.exit(1);
130
- }
134
+ // Auto-provisions on a fresh install (env > keychain > file > generate);
135
+ // resolves eagerly so a provisioning failure surfaces before the browser opens.
136
+ resolveMasterKey();
131
137
  const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
132
138
  // CSRF protection for the OAuth callback (RFC 6749 §10.12).
133
139
  const expectedState = randomBytes(32).toString('hex');
@@ -198,9 +204,10 @@ export async function runAuthFlow(args) {
198
204
  })
199
205
  // Bind to loopback only — never expose the OAuth callback to the local network.
200
206
  .listen(4242, '127.0.0.1', () => {
201
- // Always print the URL: `open` silently no-ops on headless/SSH sessions.
207
+ // Always print the URL first: the browser launch is best-effort and
208
+ // silently does nothing on headless/SSH sessions.
202
209
  console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
203
- open(authorizeUrl, { wait: false }).then((cp) => cp.unref());
210
+ openUrl(authorizeUrl);
204
211
  });
205
212
  server.on('error', (err) => {
206
213
  if (err.code === 'EADDRINUSE') {