mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.3

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 (165) hide show
  1. package/dist/accounts.d.ts +17 -1
  2. package/dist/accounts.js +52 -15
  3. package/dist/api-probe.d.ts +5 -0
  4. package/dist/api-probe.js +20 -15
  5. package/dist/arg-normalize.js +6 -5
  6. package/dist/arg-strict.d.ts +7 -0
  7. package/dist/arg-strict.js +34 -4
  8. package/dist/auth.d.ts +4 -3
  9. package/dist/auth.js +11 -13
  10. package/dist/boot-gates.d.ts +26 -0
  11. package/dist/boot-gates.js +38 -0
  12. package/dist/cimd-cache.d.ts +38 -0
  13. package/dist/cimd-cache.js +117 -0
  14. package/dist/client.d.ts +32 -2
  15. package/dist/client.js +0 -0
  16. package/dist/compose.d.ts +11 -0
  17. package/dist/compose.js +109 -0
  18. package/dist/config-file.d.ts +13 -0
  19. package/dist/config-file.js +37 -1
  20. package/dist/discover.js +3 -1
  21. package/dist/discovery-client.d.ts +3 -0
  22. package/dist/discovery-client.js +25 -2
  23. package/dist/doctor.d.ts +6 -2
  24. package/dist/doctor.js +119 -23
  25. package/dist/executor.d.ts +3 -0
  26. package/dist/executor.js +4 -2
  27. package/dist/fanout.d.ts +9 -1
  28. package/dist/fanout.js +44 -10
  29. package/dist/fs-atomic.d.ts +11 -0
  30. package/dist/fs-atomic.js +219 -28
  31. package/dist/hint-provider.d.ts +129 -0
  32. package/dist/hint-provider.js +64 -0
  33. package/dist/http-transport.d.ts +62 -4
  34. package/dist/http-transport.js +343 -24
  35. package/dist/identity.d.ts +4 -1
  36. package/dist/identity.js +25 -6
  37. package/dist/index.js +66 -138
  38. package/dist/master-key.js +13 -5
  39. package/dist/mcp-token.d.ts +136 -19
  40. package/dist/mcp-token.js +569 -72
  41. package/dist/migrate-config.js +2 -2
  42. package/dist/oauth-as.d.ts +103 -3
  43. package/dist/oauth-as.js +374 -79
  44. package/dist/oauth-consent.d.ts +10 -0
  45. package/dist/oauth-consent.js +16 -0
  46. package/dist/outbound-allowlist.js +5 -2
  47. package/dist/reauth-hint.d.ts +1 -1
  48. package/dist/reauth-hint.js +12 -8
  49. package/dist/registry-transfer.d.ts +5 -0
  50. package/dist/registry-transfer.js +65 -5
  51. package/dist/registry.d.ts +18 -1
  52. package/dist/registry.js +71 -15
  53. package/dist/scope-catalog.d.ts +11 -0
  54. package/dist/scope-catalog.js +23 -6
  55. package/dist/scope-observability.d.ts +8 -6
  56. package/dist/scope-observability.js +29 -13
  57. package/dist/services.d.ts +15 -10
  58. package/dist/services.js +59 -15
  59. package/dist/ssrf-guard.d.ts +23 -9
  60. package/dist/ssrf-guard.js +101 -62
  61. package/dist/tenant-purge.d.ts +26 -0
  62. package/dist/tenant-purge.js +67 -0
  63. package/dist/token-store.d.ts +20 -2
  64. package/dist/token-store.js +0 -0
  65. package/dist/tools/_errors.d.ts +3 -0
  66. package/dist/tools/_errors.js +53 -24
  67. package/dist/tools/_local-files.d.ts +9 -0
  68. package/dist/tools/_local-files.js +18 -0
  69. package/dist/tools/account-wizard.d.ts +10 -1
  70. package/dist/tools/account-wizard.js +74 -17
  71. package/dist/tools/accounts-tool.d.ts +7 -0
  72. package/dist/tools/accounts-tool.js +34 -18
  73. package/dist/tools/admin.d.ts +10 -1
  74. package/dist/tools/admin.js +15 -11
  75. package/dist/tools/analytics.d.ts +10 -1
  76. package/dist/tools/analytics.js +13 -8
  77. package/dist/tools/calendar.d.ts +2 -1
  78. package/dist/tools/calendar.js +18 -14
  79. package/dist/tools/chat.d.ts +10 -1
  80. package/dist/tools/chat.js +13 -8
  81. package/dist/tools/contacts.d.ts +2 -1
  82. package/dist/tools/contacts.js +17 -13
  83. package/dist/tools/docs.d.ts +2 -1
  84. package/dist/tools/docs.js +34 -30
  85. package/dist/tools/drive.d.ts +2 -1
  86. package/dist/tools/drive.js +65 -50
  87. package/dist/tools/forms.d.ts +10 -1
  88. package/dist/tools/forms.js +19 -12
  89. package/dist/tools/generated/_shared.d.ts +2 -1
  90. package/dist/tools/generated/_shared.js +7 -3
  91. package/dist/tools/generated/admin.d.ts +2 -1
  92. package/dist/tools/generated/admin.js +265 -265
  93. package/dist/tools/generated/analytics.d.ts +2 -1
  94. package/dist/tools/generated/analytics.js +125 -125
  95. package/dist/tools/generated/appsmarket.d.ts +2 -1
  96. package/dist/tools/generated/appsmarket.js +5 -5
  97. package/dist/tools/generated/calendar.d.ts +2 -1
  98. package/dist/tools/generated/calendar.js +55 -55
  99. package/dist/tools/generated/chat.d.ts +2 -1
  100. package/dist/tools/generated/chat.js +95 -95
  101. package/dist/tools/generated/classroom.d.ts +2 -1
  102. package/dist/tools/generated/classroom.js +209 -209
  103. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  104. package/dist/tools/generated/cloudidentity.js +133 -133
  105. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  106. package/dist/tools/generated/cloudsearch.js +99 -99
  107. package/dist/tools/generated/contacts.d.ts +2 -1
  108. package/dist/tools/generated/contacts.js +29 -29
  109. package/dist/tools/generated/drive.d.ts +2 -1
  110. package/dist/tools/generated/drive.js +71 -71
  111. package/dist/tools/generated/driveactivity.d.ts +2 -1
  112. package/dist/tools/generated/driveactivity.js +3 -3
  113. package/dist/tools/generated/drivelabels.d.ts +2 -1
  114. package/dist/tools/generated/drivelabels.js +53 -53
  115. package/dist/tools/generated/forms.d.ts +2 -1
  116. package/dist/tools/generated/forms.js +7 -7
  117. package/dist/tools/generated/gmail.d.ts +2 -1
  118. package/dist/tools/generated/gmail.js +117 -117
  119. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  120. package/dist/tools/generated/groupsmigration.js +3 -3
  121. package/dist/tools/generated/groupssettings.d.ts +2 -1
  122. package/dist/tools/generated/groupssettings.js +7 -7
  123. package/dist/tools/generated/index.d.ts +2 -1
  124. package/dist/tools/generated/keep.d.ts +2 -1
  125. package/dist/tools/generated/keep.js +15 -15
  126. package/dist/tools/generated/licensing.d.ts +2 -1
  127. package/dist/tools/generated/licensing.js +15 -15
  128. package/dist/tools/generated/meet.d.ts +2 -1
  129. package/dist/tools/generated/meet.js +27 -27
  130. package/dist/tools/generated/postmaster.d.ts +2 -1
  131. package/dist/tools/generated/postmaster.js +9 -9
  132. package/dist/tools/generated/reseller.d.ts +2 -1
  133. package/dist/tools/generated/reseller.js +35 -35
  134. package/dist/tools/generated/script.d.ts +2 -1
  135. package/dist/tools/generated/script.js +33 -33
  136. package/dist/tools/generated/searchconsole.d.ts +2 -1
  137. package/dist/tools/generated/searchconsole.js +3 -3
  138. package/dist/tools/generated/sheets.d.ts +2 -1
  139. package/dist/tools/generated/sheets.js +15 -15
  140. package/dist/tools/generated/tasks.d.ts +2 -1
  141. package/dist/tools/generated/tasks.js +5 -5
  142. package/dist/tools/generated/vault.d.ts +2 -1
  143. package/dist/tools/generated/vault.js +67 -67
  144. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  145. package/dist/tools/generated/workspaceevents.js +31 -31
  146. package/dist/tools/gmail.d.ts +2 -1
  147. package/dist/tools/gmail.js +45 -33
  148. package/dist/tools/google-api.d.ts +2 -3
  149. package/dist/tools/google-api.js +30 -16
  150. package/dist/tools/meet.d.ts +10 -1
  151. package/dist/tools/meet.js +13 -9
  152. package/dist/tools/searchconsole.d.ts +2 -1
  153. package/dist/tools/searchconsole.js +17 -13
  154. package/dist/tools/sheets.d.ts +2 -1
  155. package/dist/tools/sheets.js +36 -32
  156. package/dist/tools/slides.d.ts +10 -1
  157. package/dist/tools/slides.js +17 -10
  158. package/dist/tools/tasks.d.ts +2 -1
  159. package/dist/tools/tasks.js +19 -15
  160. package/dist/trim.d.ts +5 -0
  161. package/dist/trim.js +20 -0
  162. package/dist/usage-metrics.d.ts +23 -0
  163. package/dist/usage-metrics.js +65 -47
  164. package/dist/write-control.js +10 -2
  165. package/package.json +27 -1
@@ -18,7 +18,11 @@ export interface AccountSet {
18
18
  defaultAccount?: string;
19
19
  defaultAccountSource?: 'env' | 'config';
20
20
  }
21
- export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw'): AccountSet;
21
+ export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw', opts?: {
22
+ tokenDir?: string;
23
+ emptyOk?: boolean;
24
+ }): AccountSet;
25
+ export declare function fileStamp(filePath: string, version: number): string;
22
26
  /** Live accessor: dispatch-time readers use this, never a captured snapshot. */
23
27
  export declare function getAccountSet(): AccountSet;
24
28
  /** Re-resolve after a config.json mutation and swap the live set. */
@@ -45,6 +49,18 @@ export declare function unknownAliasMessage(aliases: readonly string[], selector
45
49
  export declare function accountAliasSchemaFor(aliases: readonly string[]): z.ZodType<string>;
46
50
  /** Shared, load-time snapshot used by every tool's `account` field. */
47
51
  export declare const accountAliasSchema: z.ZodType<string>;
52
+ /** Live account argument: validates at PARSE time against the accessor's
53
+ * CURRENT aliases, so an alias added mid-session (account_add, or a tenant
54
+ * alias link) is immediately valid on already-registered tools — a baked
55
+ * z.enum freezes the boot-time set until restart. An empty set stays
56
+ * permissive (same empty-safe rule as above); dispatch still resolves the
57
+ * alias against the live registry. */
58
+ export declare function accountArgLive(aliases: () => readonly string[]): z.ZodType<string>;
59
+ /** The live membership check behind accountArgLive and the fan-out field;
60
+ * `selectors` also admits "*". An empty set stays permissive. */
61
+ export declare function liveAccountCheck(aliases: () => readonly string[], selectors: boolean): z.core.$ZodCheck<string>;
62
+ /** True for a field built on liveAccountCheck, optional or not. */
63
+ export declare function isLiveAccountField(field: unknown): boolean;
48
64
  /**
49
65
  * BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
50
66
  * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
package/dist/accounts.js CHANGED
@@ -2,8 +2,8 @@ import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { z } from 'zod';
4
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';
5
+ import { ALIAS_RE, CONFIG_VERSION, configDir, configFilePath, failStartup, isReservedAlias, loadConfigFile, mutateConfigFile } from './config-file.js';
6
+ import { closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
7
7
  const envLoad = loadEnvFiles();
8
8
  const defaultTokenDir = path.join(configDir(), 'tokens');
9
9
  const tokenDir = process.env.TOKEN_STORE_PATH
@@ -15,14 +15,14 @@ export function getTokenDir() {
15
15
  function parseCsv(value) {
16
16
  return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
17
17
  }
18
- function accountPaths(alias) {
18
+ function accountPaths(alias, dir = tokenDir) {
19
19
  return {
20
- tokenPath: path.join(tokenDir, alias, 'token.json'),
21
- encPath: path.join(tokenDir, `${alias}.enc`),
20
+ tokenPath: path.join(dir, alias, 'token.json'),
21
+ encPath: path.join(dir, `${alias}.enc`),
22
22
  };
23
23
  }
24
24
  /** v5 env parser, guards verbatim. Format: GOOGLE_ACCOUNTS="alias1:email1,alias2:email2". */
25
- function parseEnvAccounts(raw, adminAliases) {
25
+ function parseEnvAccounts(raw, adminAliases, dir = tokenDir) {
26
26
  const configs = {};
27
27
  const aliases = [];
28
28
  for (const entry of raw.split(',')) {
@@ -40,7 +40,7 @@ function parseEnvAccounts(raw, adminAliases) {
40
40
  }
41
41
  // Restrict alias to a safe charset so it can't escape `tokenDir` via path traversal
42
42
  // (e.g. "../../etc/passwd:foo@bar.com" in .env).
43
- if (!/^[a-zA-Z0-9_-]+$/.test(alias) || ['__proto__', 'constructor', 'prototype'].includes(alias)) {
43
+ if (!ALIAS_RE.test(alias) || isReservedAlias(alias)) {
44
44
  throw new Error(`Invalid alias "${alias}". Allowed characters: letters, digits, underscore, hyphen.`);
45
45
  }
46
46
  if (aliases.includes(alias)) {
@@ -49,7 +49,7 @@ function parseEnvAccounts(raw, adminAliases) {
49
49
  aliases.push(alias);
50
50
  configs[alias] = {
51
51
  email,
52
- ...accountPaths(alias),
52
+ ...accountPaths(alias, dir),
53
53
  admin: adminAliases.includes(alias) || undefined,
54
54
  source: 'env',
55
55
  };
@@ -74,7 +74,11 @@ function noAccountsMessage() {
74
74
  * empty behaves as unset, mirroring GOOGLE_ACCOUNTS semantics.
75
75
  */
76
76
  let warnedLegacy = false;
77
- export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit') {
77
+ export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit',
78
+ // emptyOk: zero accounts is a valid set, not an error, even with 'throw'
79
+ // (a context that has linked nothing yet). An invalid file still fails.
80
+ opts = {}) {
81
+ const dir = opts.tokenDir ?? tokenDir;
78
82
  const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
79
83
  const rawEnv = env.GOOGLE_ACCOUNTS;
80
84
  const fail = (slug, message) => {
@@ -91,7 +95,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
91
95
  if (bundle === 'admin') {
92
96
  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
97
  }
94
- if (!(bundle in BUNDLE_CATALOG)) {
98
+ if (!isKnownBundle(bundle)) {
95
99
  const hint = closestBundle(bundle);
96
100
  fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
97
101
  }
@@ -102,7 +106,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
102
106
  }
103
107
  }
104
108
  if (rawEnv && rawEnv.trim() !== '') {
105
- const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv);
109
+ const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv, dir);
106
110
  materializeFirstRun(aliases, configs, filePath);
107
111
  const def = resolveDefaultAccount(env, null, aliases, fail);
108
112
  return {
@@ -129,7 +133,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
129
133
  // empty (BR-4), enforced by assertServerAccountsConfigured() in index.ts.
130
134
  // The dispatch-path reload ('throw') still throws so a mid-session emptied
131
135
  // config.json keeps the last-good registry (BR-7) instead of dropping tools.
132
- if (onInvalid === 'throw') {
136
+ if (onInvalid === 'throw' && !opts.emptyOk) {
133
137
  throw new Error(`E_NO_ACCOUNTS_CONFIGURED: ${noAccountsMessage()}`);
134
138
  }
135
139
  return {
@@ -148,7 +152,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
148
152
  for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
149
153
  const bundles = resolveBundleAliases(profile.bundles);
150
154
  for (const bundle of bundles) {
151
- if (!(bundle in BUNDLE_CATALOG)) {
155
+ if (!isKnownBundle(bundle)) {
152
156
  const hint = closestBundle(bundle);
153
157
  fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in scope profile "${name}"${hint ? ` — did you mean "${hint}"?` : ''}`);
154
158
  }
@@ -167,7 +171,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
167
171
  aliases.push(alias);
168
172
  configs[alias] = {
169
173
  email: entry.email,
170
- ...accountPaths(alias),
174
+ ...accountPaths(alias, dir),
171
175
  scopeProfile: entry.scopeProfile,
172
176
  admin: adminEnv.length > 0 ? adminEnv.includes(alias) : entry.admin,
173
177
  source: 'config',
@@ -196,7 +200,7 @@ function resolveDefaultAccount(env, fromConfig, aliases, fail) {
196
200
  }
197
201
  return { defaultAccount: value, defaultAccountSource: fromEnv ? 'env' : 'config' };
198
202
  }
199
- function fileStamp(filePath, version) {
203
+ export function fileStamp(filePath, version) {
200
204
  try {
201
205
  return `${version}:${fs.statSync(filePath).mtimeMs}`;
202
206
  }
@@ -283,6 +287,8 @@ export const ACCOUNTS = current.aliases;
283
287
  * enum actually accepts, and naming a runtime-added alias as valid while
284
288
  * rejecting it would be a lie. */
285
289
  export function unknownAliasMessage(aliases, selectors = false) {
290
+ if (aliases.length === 0)
291
+ return 'No Google account is configured yet. Add one with account_add.';
286
292
  const all = selectors ? '; "*" for all accounts' : '';
287
293
  const csv = selectors && aliases.length > 1 ? `; or a CSV subset like "${aliases.slice(0, 2).join(',')}"` : '';
288
294
  return `Unknown account alias. Valid: ${aliases.join(', ')}${all}${csv}. Run account_list if accounts changed since this server started.`;
@@ -301,6 +307,37 @@ export function accountAliasSchemaFor(aliases) {
301
307
  }
302
308
  /** Shared, load-time snapshot used by every tool's `account` field. */
303
309
  export const accountAliasSchema = accountAliasSchemaFor(ACCOUNTS);
310
+ /** Live account argument: validates at PARSE time against the accessor's
311
+ * CURRENT aliases, so an alias added mid-session (account_add, or a tenant
312
+ * alias link) is immediately valid on already-registered tools — a baked
313
+ * z.enum freezes the boot-time set until restart. An empty set stays
314
+ * permissive (same empty-safe rule as above); dispatch still resolves the
315
+ * alias against the live registry. */
316
+ export function accountArgLive(aliases) {
317
+ return z.string().check(liveAccountCheck(aliases, false));
318
+ }
319
+ // The check instance survives the clones .describe() and .optional() make,
320
+ // so it is what marks a field as an account selector for the registry.
321
+ const LIVE_ACCOUNT_CHECKS = new WeakSet();
322
+ /** The live membership check behind accountArgLive and the fan-out field;
323
+ * `selectors` also admits "*". An empty set stays permissive. */
324
+ export function liveAccountCheck(aliases, selectors) {
325
+ const check = z.superRefine((value, ctx) => {
326
+ const current = aliases();
327
+ if (current.length === 0 || current.includes(value) || (selectors && value === '*'))
328
+ return;
329
+ ctx.addIssue({ code: 'custom', message: unknownAliasMessage(current, selectors) });
330
+ });
331
+ LIVE_ACCOUNT_CHECKS.add(check);
332
+ return check;
333
+ }
334
+ /** True for a field built on liveAccountCheck, optional or not. */
335
+ export function isLiveAccountField(field) {
336
+ let def = field?._zod?.def;
337
+ if (def?.type === 'optional')
338
+ def = def.innerType?._zod?.def;
339
+ return def?.checks?.some((c) => LIVE_ACCOUNT_CHECKS.has(c)) ?? false;
340
+ }
304
341
  /**
305
342
  * BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
306
343
  * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
@@ -1,3 +1,5 @@
1
+ import { getClient } from './client.js';
2
+ import { readToken } from './token-store.js';
1
3
  import type { ApiProbeResult } from './doctor.js';
2
4
  export interface ApiProbeSpec {
3
5
  service: string;
@@ -15,4 +17,7 @@ export interface ApiProbeDeps {
15
17
  grantedScopes: (alias: string) => string[];
16
18
  request: (alias: string, url: string) => Promise<void>;
17
19
  }
20
+ /** Probe deps over one context's token reads and client; the module-level
21
+ * pair is the single owner's. */
22
+ export declare function apiProbeDepsFor(readTokenFn: typeof readToken, getClientFn: typeof getClient): ApiProbeDeps;
18
23
  export declare function probeApiEnablement(alias: string, deps?: ApiProbeDeps): Promise<ApiProbeResult[]>;
package/dist/api-probe.js CHANGED
@@ -24,21 +24,26 @@ export const API_PROBES = [
24
24
  export function planProbes(granted, probes = API_PROBES) {
25
25
  return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
26
26
  }
27
- const DEFAULT_DEPS = {
28
- grantedScopes: (alias) => {
29
- try {
30
- const scope = readToken(alias)?.scope;
31
- return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
32
- }
33
- catch {
34
- return [];
35
- }
36
- },
37
- request: async (alias, url) => {
38
- const auth = await getClient(alias);
39
- await auth.request({ url, timeout: 10_000 });
40
- },
41
- };
27
+ /** Probe deps over one context's token reads and client; the module-level
28
+ * pair is the single owner's. */
29
+ export function apiProbeDepsFor(readTokenFn, getClientFn) {
30
+ return {
31
+ grantedScopes: (alias) => {
32
+ try {
33
+ const scope = readTokenFn(alias)?.scope;
34
+ return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ },
40
+ request: async (alias, url) => {
41
+ const auth = await getClientFn(alias);
42
+ await auth.request({ url, timeout: 10_000 });
43
+ },
44
+ };
45
+ }
46
+ const DEFAULT_DEPS = apiProbeDepsFor(readToken, getClient);
42
47
  export async function probeApiEnablement(alias, deps = DEFAULT_DEPS) {
43
48
  const results = [];
44
49
  for (const spec of planProbes(deps.grantedScopes(alias))) {
@@ -1,5 +1,5 @@
1
- import { screenArguments, unknownArgEnvelope, } from './arg-strict.js';
2
- import { sliceClean } from './trim.js';
1
+ import { echoName, echoNames, screenArguments, unknownArgEnvelope, } from './arg-strict.js';
2
+ import { logSafe, sliceClean } from './trim.js';
3
3
  // The SDK synthesizes input-validation failures as isError RESULTS, skipping
4
4
  // the handler entirely: no handler of ours runs, so no envelope exists and the
5
5
  // client gets free text. The text is prose in both SDK eras (v1 prefixed it
@@ -115,8 +115,9 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
115
115
  if (screened.unknown.length === 0 && screened.redundant.length === 0)
116
116
  return { action: 'forward', msg };
117
117
  const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
118
- // Key names only; argument VALUES never reach the log.
119
- log(`[args] ${tool}: undeclared ${all.join(', ')}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
118
+ // Key names only; argument VALUES never reach the log. The names are the
119
+ // caller's, so they are escaped here and only here: the envelope is JSON.
120
+ log(`[args] ${tool}: undeclared ${logSafe(echoNames(all), 600)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
120
121
  try {
121
122
  // Only ever a DECLARED key or the literal placeholder, so the metrics
122
123
  // closed-vocabulary rule holds: the caller's key is never persisted.
@@ -132,7 +133,7 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
132
133
  const siblings = screened.unknown.some((u) => u.suggestions.length > 0)
133
134
  ? []
134
135
  : (opts.siblingsFor?.(tool, screened.unknown.map((u) => u.sent)) ?? []);
135
- const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? account : undefined, siblings);
136
+ const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? echoName(account) : undefined, siblings);
136
137
  return {
137
138
  action: 'reject',
138
139
  response: {
@@ -14,6 +14,13 @@ export declare const STRICT_EXEMPT_TOOLS: ReadonlySet<string>;
14
14
  * tools: no declared key starts with `_` or contains `/`, so neither rule can
15
15
  * shadow a real parameter. */
16
16
  export declare function isExemptKey(key: string): boolean;
17
+ /** A call can carry thousands of unknown keys, or one of megabytes: only the
18
+ * first few earn a did-you-mean, and the log line and the envelope echo a
19
+ * bounded prefix of each name. The reject decision itself is never capped. */
20
+ export declare const MAX_SUGGESTED_KEYS = 8;
21
+ export declare function echoName(name: string): string;
22
+ /** The first MAX_SUGGESTED_KEYS names, each capped, then "and N more". */
23
+ export declare function echoNames(names: readonly string[], quote?: boolean): string;
17
24
  /**
18
25
  * Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
19
26
  * 0 same key modulo case and separators
@@ -9,7 +9,8 @@
9
9
  // and never touches a VALUE: it only decides that a key is undeclared, and
10
10
  // suggests what the caller probably meant. Rewriting would risk sending a real
11
11
  // value to Google on a guess, which is strictly worse than refusing.
12
- import { editDistance } from './scope-catalog.js';
12
+ import { editDistance, SUGGEST_MAX_INPUT } from './scope-catalog.js';
13
+ import { sliceClean } from './trim.js';
13
14
  /**
14
15
  * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. A typo in the VALUE still falls
15
16
  * back to `warn`, not to the default: an operator who misspells the setting
@@ -59,6 +60,20 @@ export function isExemptKey(key) {
59
60
  * alone; they match far too much to be useful. */
60
61
  const GENERIC = new Set(['id', 'name', 'title', 'body', 'parent', 'text', 'content', 'type', 'value', 'key', 'data']);
61
62
  const flatten = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
63
+ /** A call can carry thousands of unknown keys, or one of megabytes: only the
64
+ * first few earn a did-you-mean, and the log line and the envelope echo a
65
+ * bounded prefix of each name. The reject decision itself is never capped. */
66
+ export const MAX_SUGGESTED_KEYS = 8;
67
+ const MAX_ECHO_CHARS = 64;
68
+ export function echoName(name) {
69
+ return name.length > MAX_ECHO_CHARS ? `${sliceClean(name, MAX_ECHO_CHARS)}...` : name;
70
+ }
71
+ /** The first MAX_SUGGESTED_KEYS names, each capped, then "and N more". */
72
+ export function echoNames(names, quote = false) {
73
+ const shown = names.slice(0, MAX_SUGGESTED_KEYS).map((n) => (quote ? `"${echoName(n)}"` : echoName(n)));
74
+ const rest = names.length - shown.length;
75
+ return rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(', ');
76
+ }
62
77
  function tokens(s) {
63
78
  return s
64
79
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
@@ -76,6 +91,8 @@ function tokens(s) {
76
91
  * distance 6, well past any sane threshold, which is why tier 1 exists.
77
92
  */
78
93
  export function suggestKeys(unknown, declared, limit = 2) {
94
+ if (unknown.length > SUGGEST_MAX_INPUT)
95
+ return [];
79
96
  const uFlat = flatten(unknown);
80
97
  const uTok = tokens(unknown);
81
98
  const tiers = [[], [], [], []];
@@ -94,8 +111,12 @@ export function suggestKeys(unknown, declared, limit = 2) {
94
111
  tiers[2].push({ key, rank: Math.abs(kFlat.length - uFlat.length) });
95
112
  continue;
96
113
  }
114
+ const maxDist = Math.max(1, Math.floor(uFlat.length / 4));
115
+ // The distance is at least the length gap: skip the DP when that alone fails.
116
+ if (Math.abs(uFlat.length - kFlat.length) > maxDist)
117
+ continue;
97
118
  const d = editDistance(uFlat, kFlat);
98
- if (d <= Math.max(1, Math.floor(uFlat.length / 4)))
119
+ if (d <= maxDist)
99
120
  tiers[3].push({ key, rank: d });
100
121
  }
101
122
  const tier = tiers.find((t) => t.length > 0);
@@ -117,10 +138,19 @@ export function screenArguments(tool, args, declared) {
117
138
  if (STRICT_EXEMPT_TOOLS.has(tool) || declared.length === 0)
118
139
  return out;
119
140
  const declaredSet = new Set(declared);
141
+ // Tier 0 (same key modulo case and separators) is a map lookup, so every key
142
+ // gets it and a redundant twin is recognised past the ranking budget too.
143
+ // A near-miss duplicate (a typo sent next to its declared key) is recognised
144
+ // only within the budget: a call needs 9 of them before one is refused.
145
+ const byFlat = new Map();
146
+ for (const k of declared)
147
+ byFlat.set(flatten(k), [...(byFlat.get(flatten(k)) ?? []), k]);
148
+ let ranked = 0;
120
149
  for (const key of Object.keys(args)) {
121
150
  if (declaredSet.has(key) || isExemptKey(key))
122
151
  continue;
123
- const suggestions = suggestKeys(key, declared);
152
+ const twins = key.length <= SUGGEST_MAX_INPUT ? byFlat.get(flatten(key)) : undefined;
153
+ const suggestions = twins ? twins.slice(0, 2) : ranked++ < MAX_SUGGESTED_KEYS ? suggestKeys(key, declared) : [];
124
154
  // The caller sent both spellings, so the declared one already won and the
125
155
  // outcome is what it has always been. Never fail a call that works today.
126
156
  if (suggestions.some((s) => s in args)) {
@@ -133,7 +163,7 @@ export function screenArguments(tool, args, declared) {
133
163
  }
134
164
  /** The agent-facing envelope. Values are never included, only key names. */
135
165
  export function unknownArgEnvelope(tool, unknown, declared, account, siblings = []) {
136
- const names = unknown.map((u) => `"${u.sent}"`).join(', ');
166
+ const names = echoNames(unknown.map((u) => u.sent), true);
137
167
  const accepts = `This tool accepts: ${declared.join(', ')}.`;
138
168
  const suggested = unknown.flatMap((u) => u.suggestions);
139
169
  let hint;
package/dist/auth.d.ts CHANGED
@@ -1,17 +1,18 @@
1
+ import type { AccountSet } from './accounts.js';
1
2
  import { ADMIN_SCOPES } from './scope-catalog.js';
2
3
  export declare const BASE_SCOPES: string[];
3
4
  export declare const OPTIONAL_SCOPE_BUNDLES: Record<string, string[]>;
4
5
  export { ADMIN_SCOPES };
5
6
  /** Union of every account's resolved bundles: a service registers if ANY
6
7
  * account can authorize it; per-account authz happens at call time (BR2). */
7
- export declare function getOptionalBundles(): string[];
8
+ export declare function getOptionalBundles(set?: AccountSet): string[];
8
9
  /** Aliases granted ADMIN_SCOPES: per-account admin flag (env
9
10
  * GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
10
11
  * scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
11
- export declare function getAdminAccounts(): string[];
12
+ export declare function getAdminAccounts(set?: AccountSet): string[];
12
13
  /** Scopes are fixed at consent time: changing an account's profile (or the
13
14
  * legacy env) changes its consent set and requires re-running auth. Evaluated
14
15
  * per account: `work` can carry admin + gmail_settings while `personal` is
15
16
  * never asked for them. */
16
- export declare function resolveScopesForAccount(alias: string): string[];
17
+ export declare function resolveScopesForAccount(alias: string, set?: AccountSet): string[];
17
18
  export declare function runAuthFlow(args: string[]): Promise<void>;
package/dist/auth.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { openUrl } from './open-url.js';
3
3
  import { ACCOUNTS, getAccountSet } from './accounts.js';
4
- import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
4
+ import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
5
5
  import { resolveMasterKey } from './master-key.js';
6
6
  import { writeToken } from './token-store.js';
7
7
  import { buildConsentClient, openLoopbackConsent, TESTING_MODE_WARNING } from './oauth-consent.js';
@@ -48,18 +48,17 @@ function legacyGlobalProfile() {
48
48
  if (bundle === 'admin') {
49
49
  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.');
50
50
  }
51
- if (!(bundle in BUNDLE_CATALOG)) {
51
+ if (!isKnownBundle(bundle)) {
52
52
  const hint = closestBundle(bundle);
53
53
  throw new Error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
54
54
  }
55
55
  }
56
56
  return { bundles };
57
57
  }
58
- function profileForAccount(alias) {
58
+ function profileForAccount(alias, set = getAccountSet()) {
59
59
  const legacy = legacyGlobalProfile();
60
60
  if (legacy)
61
61
  return legacy;
62
- const set = getAccountSet();
63
62
  const name = set.configs[alias]?.scopeProfile ?? 'base';
64
63
  // hasOwn: a profile named like an Object.prototype member must never
65
64
  // resolve to the inherited function.
@@ -67,14 +66,13 @@ function profileForAccount(alias) {
67
66
  }
68
67
  /** Union of every account's resolved bundles: a service registers if ANY
69
68
  * account can authorize it; per-account authz happens at call time (BR2). */
70
- export function getOptionalBundles() {
69
+ export function getOptionalBundles(set = getAccountSet()) {
71
70
  const legacy = legacyGlobalProfile();
72
71
  if (legacy)
73
72
  return legacy.bundles.filter(b => b !== 'admin');
74
73
  const union = new Set();
75
- const set = getAccountSet();
76
74
  for (const alias of set.aliases) {
77
- for (const b of profileForAccount(alias).bundles) {
75
+ for (const b of profileForAccount(alias, set).bundles) {
78
76
  if (b !== 'admin')
79
77
  union.add(b);
80
78
  }
@@ -84,12 +82,12 @@ export function getOptionalBundles() {
84
82
  /** Aliases granted ADMIN_SCOPES: per-account admin flag (env
85
83
  * GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
86
84
  * scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
87
- export function getAdminAccounts() {
88
- const { aliases, configs } = getAccountSet();
85
+ export function getAdminAccounts(set = getAccountSet()) {
86
+ const { aliases, configs } = set;
89
87
  return aliases.filter((a) => {
90
88
  if (configs[a].admin === true)
91
89
  return true;
92
- const p = profileForAccount(a);
90
+ const p = profileForAccount(a, set);
93
91
  return p.admin === true || p.bundles.includes('admin');
94
92
  });
95
93
  }
@@ -97,15 +95,15 @@ export function getAdminAccounts() {
97
95
  * legacy env) changes its consent set and requires re-running auth. Evaluated
98
96
  * per account: `work` can carry admin + gmail_settings while `personal` is
99
97
  * never asked for them. */
100
- export function resolveScopesForAccount(alias) {
101
- const profile = profileForAccount(alias);
98
+ export function resolveScopesForAccount(alias, set = getAccountSet()) {
99
+ const profile = profileForAccount(alias, set);
102
100
  const scopes = profile.includesBase === false ? [] : [...BASE_SCOPES];
103
101
  for (const bundle of profile.bundles) {
104
102
  if (bundle === 'admin')
105
103
  continue;
106
104
  scopes.push(...BUNDLE_CATALOG[bundle].scopes);
107
105
  }
108
- if (getAdminAccounts().includes(alias)) {
106
+ if (getAdminAccounts(set).includes(alias)) {
109
107
  scopes.push(...ADMIN_SCOPES);
110
108
  }
111
109
  return Array.from(new Set(scopes));
@@ -0,0 +1,26 @@
1
+ export interface MtBootGates {
2
+ /** True = multi-tenant deployment: the server boots with zero accounts
3
+ * (tenants provision in-band) and the provisioning gate below replaces the
4
+ * flat owner-allowlist requirement. */
5
+ multiTenant: boolean;
6
+ /** Fail-fast replacement for the E_OWNER_EMAILS_REQUIRED check. MUST refuse
7
+ * (exit/throw) when no valid provisioning mechanism is configured — an
8
+ * ungated HTTP endpoint must never boot, whatever the condition checked. */
9
+ assertProvisioningGate: () => void;
10
+ }
11
+ export declare function setMtBootGates(g: MtBootGates | null): void;
12
+ export declare function mtBootGates(): MtBootGates | null;
13
+ export declare function isMultiTenantBoot(): boolean;
14
+ /** BR-4 applies to stdio always, and to HTTP only when single-owner: a
15
+ * multi-tenant HTTP deploy starts empty by design. */
16
+ export declare function accountsAssertRequired(wantStdio: boolean, multiTenant: boolean): boolean;
17
+ /** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
18
+ * the env registry is process-wide, so it would graft one operator's accounts
19
+ * onto every tenant and turn every tenant's first wizard call into a dead
20
+ * end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
21
+ * of one clear one. */
22
+ export declare function assertNoEnvAccountsMode(env?: NodeJS.ProcessEnv): void;
23
+ /** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
24
+ * global scope profile, so on a multi-tenant box one operator's scope choice
25
+ * would silently apply to every tenant's consent. */
26
+ export declare function assertNoEnvOptionalScopesMode(env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,38 @@
1
+ // S1.16: multi-tenant boot gates. The free core never sets these — every
2
+ // helper reduces to today's single-owner behavior when no gates are
3
+ // installed. Deliberately a side-effect-free module (accounts.ts/auth.ts
4
+ // resolve env at import time) so the tenancy module can import it cheaply.
5
+ import { failStartup } from './config-file.js';
6
+ let gates = null;
7
+ export function setMtBootGates(g) {
8
+ gates = g;
9
+ }
10
+ export function mtBootGates() {
11
+ return gates;
12
+ }
13
+ export function isMultiTenantBoot() {
14
+ return gates?.multiTenant === true;
15
+ }
16
+ /** BR-4 applies to stdio always, and to HTTP only when single-owner: a
17
+ * multi-tenant HTTP deploy starts empty by design. */
18
+ export function accountsAssertRequired(wantStdio, multiTenant) {
19
+ return wantStdio || !multiTenant;
20
+ }
21
+ /** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
22
+ * the env registry is process-wide, so it would graft one operator's accounts
23
+ * onto every tenant and turn every tenant's first wizard call into a dead
24
+ * end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
25
+ * of one clear one. */
26
+ export function assertNoEnvAccountsMode(env = process.env) {
27
+ if (env.GOOGLE_ACCOUNTS?.trim()) {
28
+ failStartup('E_ENV_ACCOUNTS_MODE_MT', 'GOOGLE_ACCOUNTS must not be set on a multi-tenant deployment; accounts are linked in-band per tenant. Unset it and restart.');
29
+ }
30
+ }
31
+ /** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
32
+ * global scope profile, so on a multi-tenant box one operator's scope choice
33
+ * would silently apply to every tenant's consent. */
34
+ export function assertNoEnvOptionalScopesMode(env = process.env) {
35
+ if (env.GOOGLE_OPTIONAL_SCOPES?.trim()) {
36
+ failStartup('E_ENV_OPTIONAL_SCOPES_MT', 'GOOGLE_OPTIONAL_SCOPES must not be set on a multi-tenant deployment; scopes are chosen per tenant at consent. Unset it and restart.');
37
+ }
38
+ }
@@ -0,0 +1,38 @@
1
+ export declare const CIMD_TTL_MS: number;
2
+ /** The oldest validated document still served while the cap is full or the issuer cannot answer. */
3
+ export declare const CIMD_MAX_STALE_MS: number;
4
+ /** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
5
+ export declare const CIMD_MAX_IN_FLIGHT = 4;
6
+ export declare const CIMD_CACHE_MAX = 256;
7
+ export type CimdDocument = Record<string, unknown>;
8
+ type CimdError = {
9
+ error: 'E_CIMD_SSRF_BLOCKED' | 'E_CIMD_INVALID';
10
+ };
11
+ /** `fresh` is true only for the request whose fetch produced the document. */
12
+ export type CimdResolution = {
13
+ doc: CimdDocument;
14
+ fresh: boolean;
15
+ } | {
16
+ busy: true;
17
+ } | CimdError;
18
+ export interface CimdCacheDeps {
19
+ fetch: (clientId: string) => Promise<CimdDocument>;
20
+ now: () => number;
21
+ log: (line: string) => void;
22
+ }
23
+ export declare class CimdClientCache {
24
+ private readonly deps;
25
+ private readonly entries;
26
+ private readonly inFlight;
27
+ private busyLoggedAt;
28
+ private busySuppressed;
29
+ constructor(deps: CimdCacheDeps);
30
+ get size(): number;
31
+ /** `validate` runs once per fetch, in the request that started it, and every
32
+ * request waiting on that fetch gets the same answer. */
33
+ resolve(clientId: string, validate: (doc: CimdDocument) => boolean): Promise<CimdResolution>;
34
+ private fetchFor;
35
+ private store;
36
+ private noteBusy;
37
+ }
38
+ export {};