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
@@ -1,19 +1,24 @@
1
- import type { ToolRegistry } from './registry.js';
1
+ import type { AccountSet } from './accounts.js';
2
+ import type { CuratedToolDeps } from './client.js';
3
+ import { type ToolRegistry } from './registry.js';
2
4
  export interface ServiceEntry {
3
5
  name: string;
4
- register: (registry: ToolRegistry) => void;
5
- enabled?: () => boolean;
6
+ register: (registry: ToolRegistry, deps?: CuratedToolDeps) => void;
7
+ /** Absent = always on. Gates evaluate against the PASSED account set (the
8
+ * calling tenant's view); omitted set = the global registry, today's
9
+ * behavior. */
10
+ enabled?: (set?: AccountSet) => boolean;
6
11
  }
7
12
  export declare const SERVICES: ServiceEntry[];
8
13
  export declare const GENERATED_GATES: Record<string, {
9
- enabled: () => boolean;
14
+ enabled: (set?: AccountSet) => boolean;
10
15
  hint: string;
11
16
  }>;
17
+ export declare function unknownToolMessage(registry: ToolRegistry, rawName: string): string;
12
18
  /**
13
- * Message for a `tools/call` naming a tool that is not registered. The SDK's
14
- * own answer is a bare "Tool X not found", which reads identically for a typo,
15
- * for a service gated behind a scope bundle, and for an API this server has
16
- * never heard of. Those need different next steps, and the server knows which
17
- * is which: it composed the enabled-service list at boot.
19
+ * Gated services the registry's CURRENT accounts enable but that registered
20
+ * nothing: an account added mid-session unlocked them after buildRegistry
21
+ * decided the gates. Mirrors compose.ts gate for gate, so an ungated or
22
+ * toolset-excluded service is never reported.
18
23
  */
19
- export declare function unknownToolMessage(registry: ToolRegistry, name: string): string;
24
+ export declare function servicesAwaitingRestart(registry: ToolRegistry): string[];
package/dist/services.js CHANGED
@@ -13,8 +13,12 @@ import { registerChatTools } from './tools/chat.js';
13
13
  import { registerAdminTools } from './tools/admin.js';
14
14
  import { registerAnalyticsTools } from './tools/analytics.js';
15
15
  import { getOptionalBundles, getAdminAccounts } from './auth.js';
16
+ import { serviceOf } from './registry.js';
17
+ import { getToolsets, toolsetEnabled } from './toolsets.js';
16
18
  import { GENERATED_SERVICES } from './tools/generated/index.js';
17
19
  import { suggestKeys } from './arg-strict.js';
20
+ import { sliceClean } from './trim.js';
21
+ import { hints } from './hint-provider.js';
18
22
  export const SERVICES = [
19
23
  { name: 'gmail', register: registerGmailTools },
20
24
  { name: 'drive', register: registerDriveTools },
@@ -25,16 +29,16 @@ export const SERVICES = [
25
29
  { name: 'searchconsole', register: registerSearchConsoleTools },
26
30
  { name: 'tasks', register: registerTasksTools },
27
31
  { name: 'meet', register: registerMeetTools },
28
- { name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
29
- { name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
30
- { name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
31
- { name: 'analytics', register: registerAnalyticsTools, enabled: () => { const b = new Set(getOptionalBundles()); return b.has('analytics') || b.has('analytics_write'); } },
32
- { name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
32
+ { name: 'slides', register: registerSlidesTools, enabled: (set) => new Set(getOptionalBundles(set)).has('slides') },
33
+ { name: 'forms', register: registerFormsTools, enabled: (set) => new Set(getOptionalBundles(set)).has('forms') },
34
+ { name: 'chat', register: registerChatTools, enabled: (set) => new Set(getOptionalBundles(set)).has('chat') },
35
+ { name: 'analytics', register: registerAnalyticsTools, enabled: (set) => { const b = new Set(getOptionalBundles(set)); return b.has('analytics') || b.has('analytics_write'); } },
36
+ { name: 'admin', register: registerAdminTools, enabled: (set) => getAdminAccounts(set).length > 0 },
33
37
  ];
34
38
  // Generated-only services with opt-in scopes; admin/forms/chat/analytics reuse their curated gate in buildRegistry,
35
39
  // and workspaceevents is deliberately absent — no dedicated scope (subscriptions use resource scopes).
36
40
  const bundleGate = (name) => ({
37
- enabled: () => new Set(getOptionalBundles()).has(name),
41
+ enabled: (set) => new Set(getOptionalBundles(set)).has(name),
38
42
  hint: `add "${name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`,
39
43
  });
40
44
  export const GENERATED_GATES = {
@@ -60,19 +64,59 @@ export const GENERATED_GATES = {
60
64
  * never heard of. Those need different next steps, and the server knows which
61
65
  * is which: it composed the enabled-service list at boot.
62
66
  */
63
- export function unknownToolMessage(registry, name) {
64
- const near = suggestKeys(name, registry.toolNames(), 3);
65
- if (near.length > 0)
66
- return `Tool ${name} not found. Did you mean: ${near.join(', ')}?`;
67
- // A service that EXISTS in the build but registered nothing is gated, not
68
- // misspelled, and the fix is a scope bundle rather than a different name.
69
- const service = name.split('_')[0];
67
+ const MAX_TOOL_NAME_ECHO = 64;
68
+ export function unknownToolMessage(registry, rawName) {
69
+ // The name is the caller's, unvalidated: echo a bounded prefix only.
70
+ const name = rawName.length > MAX_TOOL_NAME_ECHO ? `${sliceClean(rawName, MAX_TOOL_NAME_ECHO)}...` : rawName;
71
+ // A service that EXISTS in the build but registered nothing is off, not
72
+ // misspelled. Checked before the did-you-mean, which would otherwise point
73
+ // chat_spaces_get at meet_spaces_get.
74
+ const service = serviceOf(name);
70
75
  const known = SERVICES.some((s) => s.name === service) || GENERATED_SERVICES.some((s) => s.name === service);
71
76
  if (known && !registry.services().includes(service)) {
72
- const hint = service === 'admin'
77
+ const aliases = registry.accountSet().aliases;
78
+ if (!toolsetEnabled(getToolsets(), service)) {
79
+ const rendered = hints()?.render({ kind: 'service_not_enabled', tool: name, service, aliases, admin: false, toolsetOff: true, awaitingRestart: false });
80
+ return rendered?.hint ?? `Tool ${name} not found: the "${service}" service is turned off in this deployment (GOOGLE_TOOLSETS).`;
81
+ }
82
+ if (servicesAwaitingRestart(registry).includes(service)) {
83
+ const rendered = hints()?.render({ kind: 'service_not_enabled', tool: name, service, aliases, admin: false, toolsetOff: false, awaitingRestart: true });
84
+ return rendered?.hint ?? `Tool ${name} not found: the "${service}" service was enabled after this server started, and tools register at startup. Restart the server to use them. Until then, google_api_call can reach the same API.`;
85
+ }
86
+ const isAdmin = service === 'admin';
87
+ const hint = isAdmin
73
88
  ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS), then re-auth'
74
89
  : (GENERATED_GATES[service]?.hint ?? `add "${service}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES), then re-auth`);
75
- return `Tool ${name} not found: the "${service}" service is not enabled in this deployment. To enable it, ${hint}. Until then, google_api_call can reach the same API.`;
90
+ const rendered = hints()?.render({
91
+ kind: 'service_not_enabled',
92
+ tool: name,
93
+ service,
94
+ bundle: isAdmin ? undefined : service,
95
+ aliases,
96
+ admin: isAdmin,
97
+ toolsetOff: false,
98
+ awaitingRestart: false,
99
+ });
100
+ return rendered?.hint ?? `Tool ${name} not found: the "${service}" service is not enabled in this deployment. To enable it, ${hint}. Until then, google_api_call can reach the same API.`;
76
101
  }
102
+ const near = suggestKeys(rawName, registry.toolNames(), 3);
103
+ if (near.length > 0)
104
+ return `Tool ${name} not found. Did you mean: ${near.join(', ')}?`;
77
105
  return `Tool ${name} not found. Call {service}_discover to list a service's tools, or google_api_search to find a method on any Google API.`;
78
106
  }
107
+ /**
108
+ * Gated services the registry's CURRENT accounts enable but that registered
109
+ * nothing: an account added mid-session unlocked them after buildRegistry
110
+ * decided the gates. Mirrors compose.ts gate for gate, so an ungated or
111
+ * toolset-excluded service is never reported.
112
+ */
113
+ export function servicesAwaitingRestart(registry) {
114
+ const toolsets = getToolsets();
115
+ const registered = new Set(registry.services());
116
+ const live = registry.accountSet();
117
+ const names = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((g) => g.name)])];
118
+ return names.filter((n) => {
119
+ const gate = SERVICES.find((s) => s.name === n)?.enabled ?? GENERATED_GATES[n]?.enabled;
120
+ return gate !== undefined && toolsetEnabled(toolsets, n) && !registered.has(n) && gate(live);
121
+ });
122
+ }
@@ -1,6 +1,12 @@
1
1
  export declare class SsrfBlockedError extends Error {
2
2
  constructor(message: string);
3
3
  }
4
+ /** A non-2xx answer, kept apart so the cache can tell an issuer that is
5
+ * overloaded for now from one that says the document is gone. */
6
+ export declare class CimdHttpError extends SsrfBlockedError {
7
+ readonly status: number;
8
+ constructor(status: number);
9
+ }
4
10
  /** True if an IPv4 literal is in a blocked (private/link-local/loopback/etc) range. */
5
11
  export declare function isBlockedIPv4(ip: string): boolean;
6
12
  /** True if an IPv6 literal is blocked. */
@@ -10,28 +16,35 @@ export interface SsrfDeps {
10
16
  /** Injectable resolver (defaults to dns.lookup all-addresses) for tests. */
11
17
  resolveAll?: (host: string) => Promise<string[]>;
12
18
  }
19
+ /** The most lookups started here that may be unsettled at once, abandoned ones included; process-wide, as the libuv threadpool is. */
20
+ export declare const MAX_UNSETTLED_LOOKUPS = 4;
13
21
  /**
14
22
  * Assert a URL is safe to fetch server-side: HTTPS scheme, and every address
15
- * the host resolves to is public. Throws SsrfBlockedError otherwise, and
16
- * returns the resolved IPs.
23
+ * the host resolves to is public. Throws SsrfBlockedError otherwise, except
24
+ * that a resolver error with a transient code (a lookup that could not answer
25
+ * this time) is rethrown as is. Returns the resolved IPs.
17
26
  *
18
27
  * NOTE: this checks the host's addresses but does NOT pin them — the caller
19
28
  * fetches by hostname, so undici re-resolves at connect time (a check-time vs
20
29
  * connect-time TOCTOU / short-TTL rebind window). We deliberately do not pin
21
30
  * (CDN IPs rotate). The real anti-SSRF control is the caller-side issuer
22
31
  * allowlist (oauth-as validateClient rejects any non-allowlisted host BEFORE
23
- * fetching), so an attacker can't steer the fetch at an arbitrary hostname;
24
- * this public-range check is defense-in-depth on top of that.
32
+ * fetching, and fetchCimdDocument follows no redirect, so no hop leaves it),
33
+ * so an attacker can't steer the fetch at an arbitrary hostname; this
34
+ * public-range check is defense-in-depth on top of that.
25
35
  */
26
36
  export declare function assertPublicHttpsUrl(rawUrl: string, deps?: SsrfDeps): Promise<{
27
37
  url: URL;
28
38
  addresses: string[];
29
39
  }>;
30
40
  export interface CimdFetchOptions {
41
+ /** Default 5120: CIMD section 8.7 recommends at most 5 KB. */
31
42
  maxBytes?: number;
43
+ /** One attempt's cap, never past what is left of deadlineMs (default 5000). */
32
44
  timeoutMs?: number;
33
- maxRedirects?: number;
34
- /** Transient-network-error retries per hop (default 2 → up to 3 attempts). */
45
+ /** One budget for every attempt, DNS lookups and backoff included (default 8000). */
46
+ deadlineMs?: number;
47
+ /** Transient-network-error retries (default 1, so at most 2 attempts). */
35
48
  retries?: number;
36
49
  /** Base backoff between retries in ms; grows linearly per attempt (default 200). */
37
50
  retryBackoffMs?: number;
@@ -40,9 +53,10 @@ export interface CimdFetchOptions {
40
53
  sleepImpl?: (ms: number) => Promise<void>;
41
54
  ssrf?: SsrfDeps;
42
55
  }
56
+ export declare function isTransientFetchError(e: unknown): boolean;
43
57
  /**
44
- * SSRF-guarded fetch of a CIMD client-metadata document (JSON). Every hop
45
- * (including redirects) is HTTPS + public-IP checked; response is size- and
46
- * time-capped. Returns the parsed JSON object.
58
+ * SSRF-guarded fetch of a CIMD client-metadata document (JSON): HTTPS and
59
+ * public-IP checked, no redirect followed, size-capped, and every attempt
60
+ * inside one deadline. Returns the parsed JSON object.
47
61
  */
48
62
  export declare function fetchCimdDocument(rawUrl: string, opts?: CimdFetchOptions): Promise<Record<string, unknown>>;
@@ -1,8 +1,9 @@
1
1
  // B13 / C11: SSRF guard for every server-side fetch (the CIMD client_id doc).
2
2
  // D4 decision (dependencies-and-bv §2.2): a small AUDITED private-range check,
3
3
  // no dependency — HTTPS-only, resolve-then-check ALL addresses (anti-rebind),
4
- // and cap redirects / size / time. Blocks 10/8, 172.16/12, 192.168/16, 127/8,
5
- // 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and v4-mapped forms.
4
+ // no redirects followed, and capped size and time. Blocks 10/8, 172.16/12,
5
+ // 192.168/16, 127/8, 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and
6
+ // v4-mapped forms.
6
7
  import { lookup } from 'node:dns/promises';
7
8
  import { isIPv4, isIPv6, BlockList } from 'node:net';
8
9
  export class SsrfBlockedError extends Error {
@@ -11,6 +12,16 @@ export class SsrfBlockedError extends Error {
11
12
  this.name = 'SsrfBlockedError';
12
13
  }
13
14
  }
15
+ /** A non-2xx answer, kept apart so the cache can tell an issuer that is
16
+ * overloaded for now from one that says the document is gone. */
17
+ export class CimdHttpError extends SsrfBlockedError {
18
+ status;
19
+ constructor(status) {
20
+ super(`CIMD fetch failed: HTTP ${status}`);
21
+ this.name = 'CimdHttpError';
22
+ this.status = status;
23
+ }
24
+ }
14
25
  /** True if an IPv4 literal is in a blocked (private/link-local/loopback/etc) range. */
15
26
  export function isBlockedIPv4(ip) {
16
27
  const parts = ip.split('.').map(Number);
@@ -61,25 +72,45 @@ export function isBlockedIp(ip) {
61
72
  return isBlockedIPv6(ip);
62
73
  return true; // unparseable → block
63
74
  }
75
+ /** The most lookups started here that may be unsettled at once, abandoned ones included; process-wide, as the libuv threadpool is. */
76
+ export const MAX_UNSETTLED_LOOKUPS = 4;
77
+ let unsettledLookups = 0;
78
+ /** The lookup cap refused to start one: transient for the cache, but never retried, since the hung lookups it waits on outlast any backoff. */
79
+ class LookupBusyError extends Error {
80
+ code = 'EAI_AGAIN';
81
+ }
64
82
  async function defaultResolveAll(host) {
65
83
  // A bare IP literal needs no DNS; check it directly.
66
84
  if (isIPv4(host) || isIPv6(host))
67
85
  return [host];
68
- const results = await lookup(host, { all: true });
69
- return results.map((r) => r.address);
86
+ // getaddrinfo keeps its thread until it returns, even once no attempt waits
87
+ // on it, so a new lookup past the cap would only queue behind the hung ones.
88
+ if (unsettledLookups >= MAX_UNSETTLED_LOOKUPS) {
89
+ throw new LookupBusyError(`DNS lookup for ${host} not started: ${MAX_UNSETTLED_LOOKUPS} earlier lookups have not returned`);
90
+ }
91
+ unsettledLookups++;
92
+ try {
93
+ const results = await lookup(host, { all: true });
94
+ return results.map((r) => r.address);
95
+ }
96
+ finally {
97
+ unsettledLookups--;
98
+ }
70
99
  }
71
100
  /**
72
101
  * Assert a URL is safe to fetch server-side: HTTPS scheme, and every address
73
- * the host resolves to is public. Throws SsrfBlockedError otherwise, and
74
- * returns the resolved IPs.
102
+ * the host resolves to is public. Throws SsrfBlockedError otherwise, except
103
+ * that a resolver error with a transient code (a lookup that could not answer
104
+ * this time) is rethrown as is. Returns the resolved IPs.
75
105
  *
76
106
  * NOTE: this checks the host's addresses but does NOT pin them — the caller
77
107
  * fetches by hostname, so undici re-resolves at connect time (a check-time vs
78
108
  * connect-time TOCTOU / short-TTL rebind window). We deliberately do not pin
79
109
  * (CDN IPs rotate). The real anti-SSRF control is the caller-side issuer
80
110
  * allowlist (oauth-as validateClient rejects any non-allowlisted host BEFORE
81
- * fetching), so an attacker can't steer the fetch at an arbitrary hostname;
82
- * this public-range check is defense-in-depth on top of that.
111
+ * fetching, and fetchCimdDocument follows no redirect, so no hop leaves it),
112
+ * so an attacker can't steer the fetch at an arbitrary hostname; this
113
+ * public-range check is defense-in-depth on top of that.
83
114
  */
84
115
  export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
85
116
  let url;
@@ -98,6 +129,11 @@ export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
98
129
  addresses = await resolveAll(url.hostname);
99
130
  }
100
131
  catch (e) {
132
+ // A resolver that could not answer this time says nothing about the host,
133
+ // so the fetch may retry it; a name that does not exist stays a block.
134
+ const code = e?.code;
135
+ if (typeof code === 'string' && TRANSIENT_CODES.has(code))
136
+ throw e;
101
137
  throw new SsrfBlockedError(`DNS resolution failed for ${url.hostname}: ${e.message}`);
102
138
  }
103
139
  if (addresses.length === 0)
@@ -124,7 +160,7 @@ const TRANSIENT_CODES = new Set([
124
160
  'UND_ERR_CONNECT_TIMEOUT',
125
161
  'UND_ERR_SOCKET',
126
162
  ]);
127
- function isTransientFetchError(e) {
163
+ export function isTransientFetchError(e) {
128
164
  if (e instanceof SsrfBlockedError)
129
165
  return false; // deterministic security/shape reject
130
166
  const name = e?.name;
@@ -140,73 +176,76 @@ function isTransientFetchError(e) {
140
176
  return true;
141
177
  return false;
142
178
  }
179
+ // dns.lookup cannot be cancelled, so an attempt stops waiting on it instead.
180
+ function untilAborted(work, signal) {
181
+ return new Promise((resolve, reject) => {
182
+ const onAbort = () => reject(signal.reason);
183
+ signal.addEventListener('abort', onAbort, { once: true });
184
+ void work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
185
+ });
186
+ }
143
187
  /**
144
- * SSRF-guarded fetch of a CIMD client-metadata document (JSON). Every hop
145
- * (including redirects) is HTTPS + public-IP checked; response is size- and
146
- * time-capped. Returns the parsed JSON object.
188
+ * SSRF-guarded fetch of a CIMD client-metadata document (JSON): HTTPS and
189
+ * public-IP checked, no redirect followed, size-capped, and every attempt
190
+ * inside one deadline. Returns the parsed JSON object.
147
191
  */
148
192
  export async function fetchCimdDocument(rawUrl, opts = {}) {
149
- const maxBytes = opts.maxBytes ?? 64 * 1024;
193
+ const maxBytes = opts.maxBytes ?? 5120;
150
194
  const timeoutMs = opts.timeoutMs ?? 5000;
151
- const maxRedirects = opts.maxRedirects ?? 3;
152
- const retries = opts.retries ?? 2;
195
+ const deadline = Date.now() + (opts.deadlineMs ?? 8000);
196
+ const retries = opts.retries ?? 1;
153
197
  const backoffMs = opts.retryBackoffMs ?? 200;
154
198
  const doFetch = opts.fetchImpl ?? fetch;
155
199
  const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
156
- let current = rawUrl;
157
- for (let hop = 0; hop <= maxRedirects; hop++) {
158
- let redirectTo = null;
159
- let doc = null;
160
- // Retry transient connection failures IN PLACE. Each attempt re-runs the
161
- // resolve-then-check so a retry can never skip the anti-rebind guard, and
162
- // the timer is per-attempt (armed across the body read, #10).
163
- for (let attempt = 0;; attempt++) {
164
- await assertPublicHttpsUrl(current, opts.ssrf);
165
- const controller = new AbortController();
166
- const timer = setTimeout(() => controller.abort(), timeoutMs);
200
+ // Retry fast transient failures IN PLACE. Each attempt re-runs the
201
+ // resolve-then-check so a retry can never skip the anti-rebind guard, and
202
+ // the timer is per-attempt (armed across the lookup and the body read, #10).
203
+ for (let attempt = 0;; attempt++) {
204
+ const controller = new AbortController();
205
+ let timedOut = false;
206
+ const timer = setTimeout(() => {
207
+ timedOut = true;
208
+ controller.abort();
209
+ }, Math.max(0, Math.min(timeoutMs, deadline - Date.now())));
210
+ try {
211
+ await untilAborted(assertPublicHttpsUrl(rawUrl, opts.ssrf), controller.signal);
212
+ const res = await doFetch(rawUrl, { redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' } });
213
+ // CIMD section 5: a redirect is never followed. Only this URL passed the
214
+ // issuer allowlist, and a document behind a redirect could claim its client_id.
215
+ if (res.status >= 300 && res.status < 400)
216
+ throw new SsrfBlockedError('CIMD redirect refused');
217
+ if (!res.ok)
218
+ throw new CimdHttpError(res.status);
219
+ const text = await readCapped(res, maxBytes, controller);
220
+ let parsed;
167
221
  try {
168
- const res = await doFetch(current, { redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' } });
169
- if (res.status >= 300 && res.status < 400) {
170
- const loc = res.headers.get('location');
171
- if (!loc)
172
- throw new SsrfBlockedError('redirect without a Location header');
173
- redirectTo = loc;
174
- break;
175
- }
176
- if (!res.ok)
177
- throw new SsrfBlockedError(`CIMD fetch failed: HTTP ${res.status}`);
178
- const text = await readCapped(res, maxBytes, controller);
179
- let parsed;
180
- try {
181
- parsed = JSON.parse(text);
182
- }
183
- catch {
184
- throw new SsrfBlockedError('CIMD document is not valid JSON');
185
- }
186
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
187
- throw new SsrfBlockedError('CIMD document is not a JSON object');
188
- }
189
- doc = parsed;
190
- break;
222
+ parsed = JSON.parse(text);
191
223
  }
192
- catch (e) {
193
- if (attempt < retries && isTransientFetchError(e)) {
194
- await sleep(backoffMs * (attempt + 1));
195
- continue;
196
- }
197
- throw e;
224
+ catch {
225
+ throw new SsrfBlockedError('CIMD document is not valid JSON');
198
226
  }
199
- finally {
200
- clearTimeout(timer);
227
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
228
+ throw new SsrfBlockedError('CIMD document is not a JSON object');
229
+ }
230
+ return parsed;
231
+ }
232
+ catch (e) {
233
+ // A refused status throws with the body unread, and only an abort hands
234
+ // that connection back; the controller is this attempt's alone.
235
+ controller.abort();
236
+ const wait = backoffMs * (attempt + 1);
237
+ // A timed-out attempt may leave a lookup hung on the threadpool, ours or
238
+ // fetch's own at connect time, and a retry would start another beside it.
239
+ if (!timedOut && !(e instanceof LookupBusyError) && attempt < retries && isTransientFetchError(e) && deadline - Date.now() > wait) {
240
+ await sleep(wait);
241
+ continue;
201
242
  }
243
+ throw e;
202
244
  }
203
- if (redirectTo !== null) {
204
- current = new URL(redirectTo, current).toString();
205
- continue;
245
+ finally {
246
+ clearTimeout(timer);
206
247
  }
207
- return doc;
208
248
  }
209
- throw new SsrfBlockedError(`too many redirects (> ${maxRedirects})`);
210
249
  }
211
250
  /** Read a response body, aborting the moment it exceeds maxBytes (never buffers
212
251
  * an oversized body). Falls back to a capped arrayBuffer when there is no
@@ -0,0 +1,26 @@
1
+ import type { TokenData } from './types.js';
2
+ /** Best-effort Google-side revocation (RFC 7009 endpoint). Returns whether
3
+ * Google acknowledged; NEVER throws — a network failure must not block the
4
+ * local purge (the local copy is the thing we control). */
5
+ export declare function revokeGoogleToken(tokenData: {
6
+ refresh_token?: string;
7
+ access_token?: string;
8
+ }, fetchFn?: typeof fetch): Promise<boolean>;
9
+ export interface TenantTokenPurge {
10
+ /** Aliases whose .enc files were removed. */
11
+ removed: string[];
12
+ /** How many tokens Google acknowledged revoking (best-effort). */
13
+ revoked: number;
14
+ }
15
+ /** Remove every `.enc` under the tenant's token dir, optionally revoking each
16
+ * at Google first. Decrypt failures still purge the file — an unreadable
17
+ * token is exactly the thing to delete. Only THIS tenant's dir is touched. */
18
+ export declare function purgeTenantTokens(tenantId: string, opts?: {
19
+ env?: NodeJS.ProcessEnv;
20
+ revoke?: (t: TokenData) => Promise<boolean>;
21
+ resolveKey?: () => string;
22
+ }): Promise<TenantTokenPurge>;
23
+ /** Remove the whole tenants/<id> directory (config + tokens). The id is
24
+ * validated by the path helper, so a traversal-shaped id throws before any
25
+ * filesystem access. */
26
+ export declare function removeTenantDir(tenantId: string, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,67 @@
1
+ // S1.21: tenant offboarding primitives. Core owns the reusable token-purge
2
+ // legs (generically useful, tenant-id-agnostic); the tenant_delete
3
+ // ORCHESTRATION (what to purge, in what order, from which admin surface)
4
+ // lives with the tenancy module that calls these.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { tenantConfigFilePath, tenantTokenDir } from './config-file.js';
8
+ import { createTokenStore } from './token-store.js';
9
+ /** Best-effort Google-side revocation (RFC 7009 endpoint). Returns whether
10
+ * Google acknowledged; NEVER throws — a network failure must not block the
11
+ * local purge (the local copy is the thing we control). */
12
+ export async function revokeGoogleToken(tokenData, fetchFn = fetch) {
13
+ const token = tokenData.refresh_token ?? tokenData.access_token;
14
+ if (!token)
15
+ return false;
16
+ try {
17
+ const res = await fetchFn('https://oauth2.googleapis.com/revoke', {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
20
+ body: new URLSearchParams({ token }).toString(),
21
+ });
22
+ return res.ok;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /** Remove every `.enc` under the tenant's token dir, optionally revoking each
29
+ * at Google first. Decrypt failures still purge the file — an unreadable
30
+ * token is exactly the thing to delete. Only THIS tenant's dir is touched. */
31
+ export async function purgeTenantTokens(tenantId, opts = {}) {
32
+ const dir = tenantTokenDir(tenantId, opts.env);
33
+ const store = createTokenStore(dir, { tenantId, resolveKey: opts.resolveKey });
34
+ const removed = [];
35
+ let revoked = 0;
36
+ let names;
37
+ try {
38
+ names = fs.readdirSync(dir);
39
+ }
40
+ catch {
41
+ return { removed, revoked }; // no token dir = nothing to purge
42
+ }
43
+ for (const name of names) {
44
+ if (!name.endsWith('.enc'))
45
+ continue;
46
+ const alias = name.slice(0, -'.enc'.length);
47
+ if (opts.revoke) {
48
+ try {
49
+ const token = store.readToken(alias);
50
+ if (token && (await opts.revoke(token)))
51
+ revoked += 1;
52
+ }
53
+ catch {
54
+ // undecryptable or invalid-alias filename: still remove the file
55
+ }
56
+ }
57
+ fs.rmSync(path.join(dir, name), { force: true });
58
+ removed.push(alias);
59
+ }
60
+ return { removed, revoked };
61
+ }
62
+ /** Remove the whole tenants/<id> directory (config + tokens). The id is
63
+ * validated by the path helper, so a traversal-shaped id throws before any
64
+ * filesystem access. */
65
+ export function removeTenantDir(tenantId, env) {
66
+ fs.rmSync(path.dirname(tenantConfigFilePath(tenantId, env)), { recursive: true, force: true });
67
+ }
@@ -1,8 +1,26 @@
1
1
  import type { TokenData } from './types.js';
2
2
  export declare function deriveKey(masterKey: string): Buffer;
3
- export declare function encryptToken(data: object, masterKey: string): string;
4
- export declare function decryptToken(fileContents: string, masterKey: string): TokenData;
3
+ export declare function deriveTenantKey(masterKey: string, tenantId: string): Buffer;
4
+ export declare function encryptToken(data: object, masterKey: string, tenantId?: string): string;
5
+ export declare function decryptToken(fileContents: string, masterKey: string, tenantId?: string): TokenData;
5
6
  export declare function readToken(alias: string): TokenData | null;
6
7
  export declare function writeToken(alias: string, data: object): void;
7
8
  export declare function updateToken(alias: string, updates: object): void;
8
9
  export declare function hasToken(alias: string): boolean;
10
+ export interface TokenStore {
11
+ readToken(alias: string): TokenData | null;
12
+ writeToken(alias: string, data: object): void;
13
+ updateToken(alias: string, updates: object): void;
14
+ hasToken(alias: string): boolean;
15
+ }
16
+ /**
17
+ * Context-scoped token store: `.enc` files live under `dir`, ciphertext keyed
18
+ * by `tenantId` when present (HKDF v2 envelope; omitted = the single-owner v1
19
+ * envelope). Same lock + atomic-write + error contract as the module-level
20
+ * functions: readToken returns null ONLY when the file is missing and throws
21
+ * on a decrypt failure.
22
+ */
23
+ export declare function createTokenStore(dir: string, opts?: {
24
+ tenantId?: string;
25
+ resolveKey?: () => string;
26
+ }): TokenStore;
Binary file
@@ -6,6 +6,9 @@ import type { Account } from '../accounts.js';
6
6
  export interface ServiceHints {
7
7
  scope?: string;
8
8
  resource?: string;
9
+ /** The scope bundle the scope hint is about, when known; carried through to
10
+ * a host-supplied provider so it can name the bundle in-band. */
11
+ bundle?: string;
9
12
  }
10
13
  export interface ErrorEnvelope {
11
14
  error: string;