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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +5 -5
  2. package/dist/accounts.d.ts +20 -4
  3. package/dist/accounts.js +68 -44
  4. package/dist/api-probe.d.ts +5 -0
  5. package/dist/api-probe.js +20 -15
  6. package/dist/arg-normalize.js +19 -38
  7. package/dist/arg-strict.d.ts +7 -0
  8. package/dist/arg-strict.js +38 -28
  9. package/dist/auth.d.ts +5 -4
  10. package/dist/auth.js +16 -23
  11. package/dist/boot-gates.d.ts +26 -0
  12. package/dist/boot-gates.js +36 -0
  13. package/dist/cimd-cache.d.ts +38 -0
  14. package/dist/cimd-cache.js +111 -0
  15. package/dist/client-config.d.ts +2 -2
  16. package/dist/client-config.js +8 -14
  17. package/dist/client.d.ts +32 -2
  18. package/dist/client.js +0 -0
  19. package/dist/compose.d.ts +18 -0
  20. package/dist/compose.js +109 -0
  21. package/dist/config-file.d.ts +13 -0
  22. package/dist/config-file.js +41 -10
  23. package/dist/discover.js +7 -10
  24. package/dist/discovery-client.d.ts +3 -0
  25. package/dist/discovery-client.js +36 -32
  26. package/dist/doctor.d.ts +9 -5
  27. package/dist/doctor.js +119 -29
  28. package/dist/env-load.js +2 -5
  29. package/dist/executor.d.ts +3 -0
  30. package/dist/executor.js +6 -6
  31. package/dist/fanout.d.ts +9 -1
  32. package/dist/fanout.js +47 -22
  33. package/dist/fs-atomic.d.ts +8 -0
  34. package/dist/fs-atomic.js +216 -29
  35. package/dist/hint-provider.d.ts +129 -0
  36. package/dist/hint-provider.js +58 -0
  37. package/dist/http-config.d.ts +2 -2
  38. package/dist/http-config.js +3 -7
  39. package/dist/http-transport.d.ts +67 -9
  40. package/dist/http-transport.js +318 -52
  41. package/dist/identity.d.ts +6 -3
  42. package/dist/identity.js +24 -8
  43. package/dist/index.js +76 -160
  44. package/dist/master-key.d.ts +3 -3
  45. package/dist/master-key.js +16 -19
  46. package/dist/mcp-token.d.ts +126 -19
  47. package/dist/mcp-token.js +557 -76
  48. package/dist/metrics-cli.d.ts +1 -1
  49. package/dist/metrics-cli.js +1 -1
  50. package/dist/metrics-tap.js +2 -5
  51. package/dist/migrate-config.d.ts +1 -2
  52. package/dist/migrate-config.js +5 -7
  53. package/dist/net-tuning.js +2 -4
  54. package/dist/oauth-as.d.ts +104 -4
  55. package/dist/oauth-as.js +368 -114
  56. package/dist/oauth-consent.d.ts +10 -0
  57. package/dist/oauth-consent.js +20 -10
  58. package/dist/outbound-allowlist.js +9 -11
  59. package/dist/reauth-hint.d.ts +1 -1
  60. package/dist/reauth-hint.js +11 -10
  61. package/dist/registry-transfer.d.ts +5 -0
  62. package/dist/registry-transfer.js +63 -11
  63. package/dist/registry.d.ts +13 -4
  64. package/dist/registry.js +100 -72
  65. package/dist/scope-catalog.d.ts +16 -0
  66. package/dist/scope-catalog.js +40 -13
  67. package/dist/scope-observability.d.ts +10 -9
  68. package/dist/scope-observability.js +34 -24
  69. package/dist/services.d.ts +18 -10
  70. package/dist/services.js +61 -22
  71. package/dist/ssrf-guard.d.ts +23 -9
  72. package/dist/ssrf-guard.js +106 -78
  73. package/dist/tenant-purge.d.ts +26 -0
  74. package/dist/tenant-purge.js +65 -0
  75. package/dist/token-store.d.ts +20 -2
  76. package/dist/token-store.js +0 -0
  77. package/dist/tools/_coerce.d.ts +4 -4
  78. package/dist/tools/_errors.d.ts +3 -0
  79. package/dist/tools/_errors.js +72 -60
  80. package/dist/tools/_local-files.d.ts +9 -0
  81. package/dist/tools/_local-files.js +22 -8
  82. package/dist/tools/account-wizard.d.ts +13 -3
  83. package/dist/tools/account-wizard.js +107 -56
  84. package/dist/tools/accounts-tool.d.ts +8 -1
  85. package/dist/tools/accounts-tool.js +35 -19
  86. package/dist/tools/admin.d.ts +10 -1
  87. package/dist/tools/admin.js +14 -11
  88. package/dist/tools/analytics.d.ts +10 -1
  89. package/dist/tools/analytics.js +14 -10
  90. package/dist/tools/calendar.d.ts +2 -1
  91. package/dist/tools/calendar.js +19 -18
  92. package/dist/tools/chat.d.ts +10 -1
  93. package/dist/tools/chat.js +12 -8
  94. package/dist/tools/contacts.d.ts +2 -1
  95. package/dist/tools/contacts.js +22 -19
  96. package/dist/tools/docs.d.ts +2 -1
  97. package/dist/tools/docs.js +35 -36
  98. package/dist/tools/drive.d.ts +2 -1
  99. package/dist/tools/drive.js +80 -74
  100. package/dist/tools/forms.d.ts +10 -1
  101. package/dist/tools/forms.js +18 -12
  102. package/dist/tools/generated/_shared.d.ts +2 -1
  103. package/dist/tools/generated/_shared.js +7 -3
  104. package/dist/tools/generated/admin.d.ts +2 -1
  105. package/dist/tools/generated/admin.js +265 -265
  106. package/dist/tools/generated/analytics.d.ts +2 -1
  107. package/dist/tools/generated/analytics.js +125 -125
  108. package/dist/tools/generated/appsmarket.d.ts +2 -1
  109. package/dist/tools/generated/appsmarket.js +5 -5
  110. package/dist/tools/generated/calendar.d.ts +2 -1
  111. package/dist/tools/generated/calendar.js +55 -55
  112. package/dist/tools/generated/chat.d.ts +2 -1
  113. package/dist/tools/generated/chat.js +95 -95
  114. package/dist/tools/generated/classroom.d.ts +2 -1
  115. package/dist/tools/generated/classroom.js +209 -209
  116. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  117. package/dist/tools/generated/cloudidentity.js +133 -133
  118. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  119. package/dist/tools/generated/cloudsearch.js +99 -99
  120. package/dist/tools/generated/contacts.d.ts +2 -1
  121. package/dist/tools/generated/contacts.js +29 -29
  122. package/dist/tools/generated/drive.d.ts +2 -1
  123. package/dist/tools/generated/drive.js +71 -71
  124. package/dist/tools/generated/driveactivity.d.ts +2 -1
  125. package/dist/tools/generated/driveactivity.js +3 -3
  126. package/dist/tools/generated/drivelabels.d.ts +2 -1
  127. package/dist/tools/generated/drivelabels.js +53 -53
  128. package/dist/tools/generated/forms.d.ts +2 -1
  129. package/dist/tools/generated/forms.js +7 -7
  130. package/dist/tools/generated/gmail.d.ts +2 -1
  131. package/dist/tools/generated/gmail.js +117 -117
  132. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  133. package/dist/tools/generated/groupsmigration.js +3 -3
  134. package/dist/tools/generated/groupssettings.d.ts +2 -1
  135. package/dist/tools/generated/groupssettings.js +7 -7
  136. package/dist/tools/generated/index.d.ts +2 -1
  137. package/dist/tools/generated/keep.d.ts +2 -1
  138. package/dist/tools/generated/keep.js +15 -15
  139. package/dist/tools/generated/licensing.d.ts +2 -1
  140. package/dist/tools/generated/licensing.js +15 -15
  141. package/dist/tools/generated/meet.d.ts +2 -1
  142. package/dist/tools/generated/meet.js +27 -27
  143. package/dist/tools/generated/postmaster.d.ts +2 -1
  144. package/dist/tools/generated/postmaster.js +9 -9
  145. package/dist/tools/generated/reseller.d.ts +2 -1
  146. package/dist/tools/generated/reseller.js +35 -35
  147. package/dist/tools/generated/script.d.ts +2 -1
  148. package/dist/tools/generated/script.js +33 -33
  149. package/dist/tools/generated/searchconsole.d.ts +2 -1
  150. package/dist/tools/generated/searchconsole.js +3 -3
  151. package/dist/tools/generated/sheets.d.ts +2 -1
  152. package/dist/tools/generated/sheets.js +15 -15
  153. package/dist/tools/generated/tasks.d.ts +2 -1
  154. package/dist/tools/generated/tasks.js +5 -5
  155. package/dist/tools/generated/vault.d.ts +2 -1
  156. package/dist/tools/generated/vault.js +67 -67
  157. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  158. package/dist/tools/generated/workspaceevents.js +31 -31
  159. package/dist/tools/gmail-mime.d.ts +1 -1
  160. package/dist/tools/gmail-mime.js +7 -12
  161. package/dist/tools/gmail.d.ts +4 -3
  162. package/dist/tools/gmail.js +67 -69
  163. package/dist/tools/google-api.d.ts +2 -3
  164. package/dist/tools/google-api.js +34 -22
  165. package/dist/tools/meet.d.ts +10 -1
  166. package/dist/tools/meet.js +12 -9
  167. package/dist/tools/searchconsole.d.ts +2 -1
  168. package/dist/tools/searchconsole.js +16 -13
  169. package/dist/tools/sheets.d.ts +2 -1
  170. package/dist/tools/sheets.js +35 -32
  171. package/dist/tools/slides.d.ts +10 -1
  172. package/dist/tools/slides.js +16 -10
  173. package/dist/tools/tasks.d.ts +2 -1
  174. package/dist/tools/tasks.js +18 -15
  175. package/dist/trim.d.ts +5 -0
  176. package/dist/trim.js +19 -0
  177. package/dist/usage-metrics.d.ts +23 -0
  178. package/dist/usage-metrics.js +67 -56
  179. package/dist/write-control.d.ts +4 -8
  180. package/dist/write-control.js +13 -6
  181. package/package.json +47 -21
@@ -1,19 +1,27 @@
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
+ /** Message for a `tools/call` naming a tool that is not registered: the SDK's
18
+ * bare "Tool X not found" reads identically for a typo, a gated service, and
19
+ * an unknown API, but the server knows which is which from its boot-time enabled-service list. */
20
+ export declare function unknownToolMessage(registry: ToolRegistry, rawName: string): string;
12
21
  /**
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.
22
+ * Gated services the registry's CURRENT accounts enable but that registered
23
+ * nothing: an account added mid-session unlocked them after buildRegistry
24
+ * decided the gates. Mirrors compose.ts gate for gate, so an ungated or
25
+ * toolset-excluded service is never reported.
18
26
  */
19
- export declare function unknownToolMessage(registry: ToolRegistry, name: string): string;
27
+ 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 = {
@@ -53,26 +57,61 @@ export const GENERATED_GATES = {
53
57
  script: bundleGate('script'),
54
58
  vault: bundleGate('vault'),
55
59
  };
56
- /**
57
- * Message for a `tools/call` naming a tool that is not registered. The SDK's
58
- * own answer is a bare "Tool X not found", which reads identically for a typo,
59
- * for a service gated behind a scope bundle, and for an API this server has
60
- * never heard of. Those need different next steps, and the server knows which
61
- * is which: it composed the enabled-service list at boot.
62
- */
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];
60
+ const MAX_TOOL_NAME_ECHO = 64;
61
+ /** Message for a `tools/call` naming a tool that is not registered: the SDK's
62
+ * bare "Tool X not found" reads identically for a typo, a gated service, and
63
+ * an unknown API, but the server knows which is which from its boot-time enabled-service list. */
64
+ export function unknownToolMessage(registry, rawName) {
65
+ // The name is the caller's, unvalidated: echo a bounded prefix only.
66
+ const name = rawName.length > MAX_TOOL_NAME_ECHO ? `${sliceClean(rawName, MAX_TOOL_NAME_ECHO)}...` : rawName;
67
+ // A service that EXISTS but registered nothing is off, not misspelled;
68
+ // checked before the did-you-mean, which would otherwise point chat_spaces_get at meet_spaces_get.
69
+ const service = serviceOf(name);
70
70
  const known = SERVICES.some((s) => s.name === service) || GENERATED_SERVICES.some((s) => s.name === service);
71
71
  if (known && !registry.services().includes(service)) {
72
- const hint = service === 'admin'
72
+ const aliases = registry.accountSet().aliases;
73
+ if (!toolsetEnabled(getToolsets(), service)) {
74
+ const rendered = hints()?.render({ kind: 'service_not_enabled', tool: name, service, aliases, admin: false, toolsetOff: true, awaitingRestart: false });
75
+ return rendered?.hint ?? `Tool ${name} not found: the "${service}" service is turned off in this deployment (GOOGLE_TOOLSETS).`;
76
+ }
77
+ if (servicesAwaitingRestart(registry).includes(service)) {
78
+ const rendered = hints()?.render({ kind: 'service_not_enabled', tool: name, service, aliases, admin: false, toolsetOff: false, awaitingRestart: true });
79
+ 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.`;
80
+ }
81
+ const isAdmin = service === 'admin';
82
+ const hint = isAdmin
73
83
  ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS), then re-auth'
74
84
  : (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.`;
85
+ const rendered = hints()?.render({
86
+ kind: 'service_not_enabled',
87
+ tool: name,
88
+ service,
89
+ bundle: isAdmin ? undefined : service,
90
+ aliases,
91
+ admin: isAdmin,
92
+ toolsetOff: false,
93
+ awaitingRestart: false,
94
+ });
95
+ 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
96
  }
97
+ const near = suggestKeys(rawName, registry.toolNames(), 3);
98
+ if (near.length > 0)
99
+ return `Tool ${name} not found. Did you mean: ${near.join(', ')}?`;
77
100
  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
101
  }
102
+ /**
103
+ * Gated services the registry's CURRENT accounts enable but that registered
104
+ * nothing: an account added mid-session unlocked them after buildRegistry
105
+ * decided the gates. Mirrors compose.ts gate for gate, so an ungated or
106
+ * toolset-excluded service is never reported.
107
+ */
108
+ export function servicesAwaitingRestart(registry) {
109
+ const toolsets = getToolsets();
110
+ const registered = new Set(registry.services());
111
+ const live = registry.accountSet();
112
+ const names = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((g) => g.name)])];
113
+ return names.filter((n) => {
114
+ const gate = SERVICES.find((s) => s.name === n)?.enabled ?? GENERATED_GATES[n]?.enabled;
115
+ return gate !== undefined && toolsetEnabled(toolsets, n) && !registered.has(n) && gate(live);
116
+ });
117
+ }
@@ -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,5 @@
1
- // B13 / C11: SSRF guard for every server-side fetch (the CIMD client_id doc).
2
- // D4 decision (dependencies-and-bv §2.2): a small AUDITED private-range check,
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.
1
+ // SSRF guard for every server-side fetch (the CIMD client_id doc): HTTPS-only,
2
+ // resolve-then-check ALL addresses (anti-rebind), no redirects, capped size/time.
6
3
  import { lookup } from 'node:dns/promises';
7
4
  import { isIPv4, isIPv6, BlockList } from 'node:net';
8
5
  export class SsrfBlockedError extends Error {
@@ -11,6 +8,16 @@ export class SsrfBlockedError extends Error {
11
8
  this.name = 'SsrfBlockedError';
12
9
  }
13
10
  }
11
+ /** A non-2xx answer, kept apart so the cache can tell an issuer that is
12
+ * overloaded for now from one that says the document is gone. */
13
+ export class CimdHttpError extends SsrfBlockedError {
14
+ status;
15
+ constructor(status) {
16
+ super(`CIMD fetch failed: HTTP ${status}`);
17
+ this.name = 'CimdHttpError';
18
+ this.status = status;
19
+ }
20
+ }
14
21
  /** True if an IPv4 literal is in a blocked (private/link-local/loopback/etc) range. */
15
22
  export function isBlockedIPv4(ip) {
16
23
  const parts = ip.split('.').map(Number);
@@ -35,10 +42,8 @@ export function isBlockedIPv4(ip) {
35
42
  return true; // 224/4 multicast + 240/4 reserved
36
43
  return false;
37
44
  }
38
- // Built-in subnet math (no hand-rolled IPv6 parsing): blocks loopback,
39
- // unspecified, ULA, link-local, multicast, and ALL v4-mapped / v4-compatible /
40
- // NAT64 / 6to4 embeddings (a public host never resolves to those, so blocking
41
- // the whole range closes the embedded-private-target gap without parsing).
45
+ // Built-in subnet math blocks v4-mapped/NAT64/6to4 embeddings too: a public
46
+ // host never resolves to those, so the whole range closes the embedding gap.
42
47
  const V6_BLOCKED = new BlockList();
43
48
  V6_BLOCKED.addSubnet('::', 96, 'ipv6'); // ::/96 = ::1, ::, and v4-compatible
44
49
  V6_BLOCKED.addSubnet('::ffff:0:0', 96, 'ipv6'); // v4-mapped
@@ -61,25 +66,45 @@ export function isBlockedIp(ip) {
61
66
  return isBlockedIPv6(ip);
62
67
  return true; // unparseable → block
63
68
  }
69
+ /** The most lookups started here that may be unsettled at once, abandoned ones included; process-wide, as the libuv threadpool is. */
70
+ export const MAX_UNSETTLED_LOOKUPS = 4;
71
+ let unsettledLookups = 0;
72
+ /** The lookup cap refused to start one: transient for the cache, but never retried, since the hung lookups it waits on outlast any backoff. */
73
+ class LookupBusyError extends Error {
74
+ code = 'EAI_AGAIN';
75
+ }
64
76
  async function defaultResolveAll(host) {
65
77
  // A bare IP literal needs no DNS; check it directly.
66
78
  if (isIPv4(host) || isIPv6(host))
67
79
  return [host];
68
- const results = await lookup(host, { all: true });
69
- return results.map((r) => r.address);
80
+ // getaddrinfo keeps its thread until it returns, even once no attempt waits
81
+ // on it, so a new lookup past the cap would only queue behind the hung ones.
82
+ if (unsettledLookups >= MAX_UNSETTLED_LOOKUPS) {
83
+ throw new LookupBusyError(`DNS lookup for ${host} not started: ${MAX_UNSETTLED_LOOKUPS} earlier lookups have not returned`);
84
+ }
85
+ unsettledLookups++;
86
+ try {
87
+ const results = await lookup(host, { all: true });
88
+ return results.map((r) => r.address);
89
+ }
90
+ finally {
91
+ unsettledLookups--;
92
+ }
70
93
  }
71
94
  /**
72
95
  * 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.
96
+ * the host resolves to is public. Throws SsrfBlockedError otherwise, except
97
+ * that a resolver error with a transient code (a lookup that could not answer
98
+ * this time) is rethrown as is. Returns the resolved IPs.
75
99
  *
76
100
  * NOTE: this checks the host's addresses but does NOT pin them — the caller
77
101
  * fetches by hostname, so undici re-resolves at connect time (a check-time vs
78
102
  * connect-time TOCTOU / short-TTL rebind window). We deliberately do not pin
79
103
  * (CDN IPs rotate). The real anti-SSRF control is the caller-side issuer
80
104
  * 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.
105
+ * fetching, and fetchCimdDocument follows no redirect, so no hop leaves it),
106
+ * so an attacker can't steer the fetch at an arbitrary hostname; this
107
+ * public-range check is defense-in-depth on top of that.
83
108
  */
84
109
  export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
85
110
  let url;
@@ -98,6 +123,11 @@ export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
98
123
  addresses = await resolveAll(url.hostname);
99
124
  }
100
125
  catch (e) {
126
+ // A resolver that could not answer this time says nothing about the host,
127
+ // so the fetch may retry it; a name that does not exist stays a block.
128
+ const code = e?.code;
129
+ if (typeof code === 'string' && TRANSIENT_CODES.has(code))
130
+ throw e;
101
131
  throw new SsrfBlockedError(`DNS resolution failed for ${url.hostname}: ${e.message}`);
102
132
  }
103
133
  if (addresses.length === 0)
@@ -109,10 +139,8 @@ export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
109
139
  }
110
140
  return { url, addresses };
111
141
  }
112
- // Connection-level failures that a retry can legitimately recover from. A dead
113
- // IPv6 route (broken egress with Happy-Eyeballs falling through), a reset, or a
114
- // DNS blip are transient; an SSRF block, a bad HTTP status, or malformed JSON
115
- // are deterministic and must NOT be retried.
142
+ // Connection-level failures a retry can recover from; an SSRF block, a bad
143
+ // HTTP status, or malformed JSON are deterministic and must NOT be retried.
116
144
  const TRANSIENT_CODES = new Set([
117
145
  'ETIMEDOUT',
118
146
  'ECONNRESET',
@@ -124,7 +152,7 @@ const TRANSIENT_CODES = new Set([
124
152
  'UND_ERR_CONNECT_TIMEOUT',
125
153
  'UND_ERR_SOCKET',
126
154
  ]);
127
- function isTransientFetchError(e) {
155
+ export function isTransientFetchError(e) {
128
156
  if (e instanceof SsrfBlockedError)
129
157
  return false; // deterministic security/shape reject
130
158
  const name = e?.name;
@@ -134,83 +162,83 @@ function isTransientFetchError(e) {
134
162
  const code = cause?.code ?? e?.code;
135
163
  if (code && TRANSIENT_CODES.has(code))
136
164
  return true;
137
- // undici surfaces connection failures as TypeError('fetch failed') with the
138
- // real reason in .cause; retry even when the cause carries no useful code.
165
+ // undici's TypeError('fetch failed') retries even with no useful .cause code.
139
166
  if (e instanceof TypeError && /fetch failed/i.test(e.message))
140
167
  return true;
141
168
  return false;
142
169
  }
170
+ // dns.lookup cannot be cancelled, so an attempt stops waiting on it instead.
171
+ function untilAborted(work, signal) {
172
+ return new Promise((resolve, reject) => {
173
+ const onAbort = () => reject(signal.reason);
174
+ signal.addEventListener('abort', onAbort, { once: true });
175
+ void work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
176
+ });
177
+ }
143
178
  /**
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.
179
+ * SSRF-guarded fetch of a CIMD client-metadata document (JSON): HTTPS and
180
+ * public-IP checked, no redirect followed, size-capped, and every attempt
181
+ * inside one deadline. Returns the parsed JSON object.
147
182
  */
148
183
  export async function fetchCimdDocument(rawUrl, opts = {}) {
149
- const maxBytes = opts.maxBytes ?? 64 * 1024;
184
+ const maxBytes = opts.maxBytes ?? 5120;
150
185
  const timeoutMs = opts.timeoutMs ?? 5000;
151
- const maxRedirects = opts.maxRedirects ?? 3;
152
- const retries = opts.retries ?? 2;
186
+ const deadline = Date.now() + (opts.deadlineMs ?? 8000);
187
+ const retries = opts.retries ?? 1;
153
188
  const backoffMs = opts.retryBackoffMs ?? 200;
154
189
  const doFetch = opts.fetchImpl ?? fetch;
155
190
  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);
191
+ // Each attempt re-runs resolve-then-check so a retry can't skip the
192
+ // anti-rebind guard; the timer is per-attempt, armed across lookup and body read.
193
+ for (let attempt = 0;; attempt++) {
194
+ const controller = new AbortController();
195
+ let timedOut = false;
196
+ const timer = setTimeout(() => {
197
+ timedOut = true;
198
+ controller.abort();
199
+ }, Math.max(0, Math.min(timeoutMs, deadline - Date.now())));
200
+ try {
201
+ await untilAborted(assertPublicHttpsUrl(rawUrl, opts.ssrf), controller.signal);
202
+ const res = await doFetch(rawUrl, { redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' } });
203
+ // CIMD section 5: a redirect is never followed. Only this URL passed the
204
+ // issuer allowlist, and a document behind a redirect could claim its client_id.
205
+ if (res.status >= 300 && res.status < 400)
206
+ throw new SsrfBlockedError('CIMD redirect refused');
207
+ if (!res.ok)
208
+ throw new CimdHttpError(res.status);
209
+ const text = await readCapped(res, maxBytes, controller);
210
+ let parsed;
167
211
  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;
212
+ parsed = JSON.parse(text);
191
213
  }
192
- catch (e) {
193
- if (attempt < retries && isTransientFetchError(e)) {
194
- await sleep(backoffMs * (attempt + 1));
195
- continue;
196
- }
197
- throw e;
214
+ catch {
215
+ throw new SsrfBlockedError('CIMD document is not valid JSON');
198
216
  }
199
- finally {
200
- clearTimeout(timer);
217
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
218
+ throw new SsrfBlockedError('CIMD document is not a JSON object');
219
+ }
220
+ return parsed;
221
+ }
222
+ catch (e) {
223
+ // A refused status throws with the body unread, and only an abort hands
224
+ // that connection back; the controller is this attempt's alone.
225
+ controller.abort();
226
+ const wait = backoffMs * (attempt + 1);
227
+ // A timed-out attempt may leave a lookup hung on the threadpool, ours or
228
+ // fetch's own at connect time, and a retry would start another beside it.
229
+ if (!timedOut && !(e instanceof LookupBusyError) && attempt < retries && isTransientFetchError(e) && deadline - Date.now() > wait) {
230
+ await sleep(wait);
231
+ continue;
201
232
  }
233
+ throw e;
202
234
  }
203
- if (redirectTo !== null) {
204
- current = new URL(redirectTo, current).toString();
205
- continue;
235
+ finally {
236
+ clearTimeout(timer);
206
237
  }
207
- return doc;
208
238
  }
209
- throw new SsrfBlockedError(`too many redirects (> ${maxRedirects})`);
210
239
  }
211
- /** Read a response body, aborting the moment it exceeds maxBytes (never buffers
212
- * an oversized body). Falls back to a capped arrayBuffer when there is no
213
- * readable stream (e.g. a test Response). */
240
+ // Aborts the moment maxBytes is exceeded (never buffers an oversized body);
241
+ // falls back to a capped arrayBuffer when there's no readable stream (e.g. a test Response).
214
242
  async function readCapped(res, maxBytes, controller) {
215
243
  const reader = res.body?.getReader?.();
216
244
  if (!reader) {
@@ -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,65 @@
1
+ // Tenant offboarding primitives: core owns the reusable token-purge legs;
2
+ // orchestration (what to purge, in what order) lives with the tenancy module calling these.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { tenantConfigFilePath, tenantTokenDir } from './config-file.js';
6
+ import { createTokenStore } from './token-store.js';
7
+ /** Best-effort Google-side revocation (RFC 7009 endpoint). Returns whether
8
+ * Google acknowledged; NEVER throws — a network failure must not block the
9
+ * local purge (the local copy is the thing we control). */
10
+ export async function revokeGoogleToken(tokenData, fetchFn = fetch) {
11
+ const token = tokenData.refresh_token ?? tokenData.access_token;
12
+ if (!token)
13
+ return false;
14
+ try {
15
+ const res = await fetchFn('https://oauth2.googleapis.com/revoke', {
16
+ method: 'POST',
17
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
18
+ body: new URLSearchParams({ token }).toString(),
19
+ });
20
+ return res.ok;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /** Remove every `.enc` under the tenant's token dir, optionally revoking each
27
+ * at Google first. Decrypt failures still purge the file — an unreadable
28
+ * token is exactly the thing to delete. Only THIS tenant's dir is touched. */
29
+ export async function purgeTenantTokens(tenantId, opts = {}) {
30
+ const dir = tenantTokenDir(tenantId, opts.env);
31
+ const store = createTokenStore(dir, { tenantId, resolveKey: opts.resolveKey });
32
+ const removed = [];
33
+ let revoked = 0;
34
+ let names;
35
+ try {
36
+ names = fs.readdirSync(dir);
37
+ }
38
+ catch {
39
+ return { removed, revoked }; // no token dir = nothing to purge
40
+ }
41
+ for (const name of names) {
42
+ if (!name.endsWith('.enc'))
43
+ continue;
44
+ const alias = name.slice(0, -'.enc'.length);
45
+ if (opts.revoke) {
46
+ try {
47
+ const token = store.readToken(alias);
48
+ if (token && (await opts.revoke(token)))
49
+ revoked += 1;
50
+ }
51
+ catch {
52
+ // undecryptable or invalid-alias filename: still remove the file
53
+ }
54
+ }
55
+ fs.rmSync(path.join(dir, name), { force: true });
56
+ removed.push(alias);
57
+ }
58
+ return { removed, revoked };
59
+ }
60
+ /** Remove the whole tenants/<id> directory (config + tokens). The id is
61
+ * validated by the path helper, so a traversal-shaped id throws before any
62
+ * filesystem access. */
63
+ export function removeTenantDir(tenantId, env) {
64
+ fs.rmSync(path.dirname(tenantConfigFilePath(tenantId, env)), { recursive: true, force: true });
65
+ }
@@ -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
@@ -1,9 +1,9 @@
1
1
  import { z } from 'zod';
2
- export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<T>>;
3
- export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
2
+ export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPreprocess<z.ZodArray<T>, unknown>;
3
+ export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPreprocess<T, unknown>;
4
4
  /** Numeric args arrive string-encoded from some clients. Arg normalization
5
5
  * runs on BOTH transports, but it only coerces keys it RENAMED, so a value
6
6
  * sent under the correct key never passes through it and the coercion has to
7
7
  * live in the schema. */
8
- export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
9
- export declare const coerceBoolean: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodBoolean>;
8
+ export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.ZodPreprocess<T, unknown>;
9
+ export declare const coerceBoolean: z.ZodPreprocess<z.ZodBoolean, unknown>;