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

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 (116) hide show
  1. package/README.md +21 -16
  2. package/dist/accounts.d.ts +44 -5
  3. package/dist/accounts.js +267 -25
  4. package/dist/auth.d.ts +11 -4
  5. package/dist/auth.js +85 -78
  6. package/dist/client-config.d.ts +90 -0
  7. package/dist/client-config.js +282 -0
  8. package/dist/client.js +10 -4
  9. package/dist/config-file.d.ts +38 -0
  10. package/dist/config-file.js +117 -0
  11. package/dist/discover.js +57 -3
  12. package/dist/doctor.d.ts +86 -0
  13. package/dist/doctor.js +384 -0
  14. package/dist/env-load.d.ts +11 -0
  15. package/dist/env-load.js +52 -0
  16. package/dist/executor.d.ts +2 -0
  17. package/dist/executor.js +2 -1
  18. package/dist/fanout.js +8 -5
  19. package/dist/fs-atomic.d.ts +3 -0
  20. package/dist/fs-atomic.js +123 -0
  21. package/dist/http-config.d.ts +35 -0
  22. package/dist/http-config.js +113 -0
  23. package/dist/http-transport.d.ts +59 -0
  24. package/dist/http-transport.js +269 -0
  25. package/dist/identity.d.ts +24 -0
  26. package/dist/identity.js +19 -0
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +199 -18
  29. package/dist/master-key.d.ts +53 -0
  30. package/dist/master-key.js +242 -0
  31. package/dist/mcp-token.d.ts +81 -0
  32. package/dist/mcp-token.js +176 -0
  33. package/dist/migrate-config.d.ts +7 -0
  34. package/dist/migrate-config.js +103 -0
  35. package/dist/migrate-tokens.js +4 -3
  36. package/dist/net-tuning.d.ts +8 -0
  37. package/dist/net-tuning.js +25 -0
  38. package/dist/oauth-as.d.ts +52 -0
  39. package/dist/oauth-as.js +457 -0
  40. package/dist/oauth-consent.d.ts +28 -0
  41. package/dist/oauth-consent.js +109 -0
  42. package/dist/open-url.d.ts +1 -0
  43. package/dist/open-url.js +30 -0
  44. package/dist/reauth-hint.d.ts +2 -0
  45. package/dist/reauth-hint.js +15 -0
  46. package/dist/registry-transfer.d.ts +41 -0
  47. package/dist/registry-transfer.js +219 -0
  48. package/dist/registry.d.ts +27 -1
  49. package/dist/registry.js +117 -6
  50. package/dist/scope-catalog.d.ts +19 -0
  51. package/dist/scope-catalog.js +182 -0
  52. package/dist/scope-observability.d.ts +78 -0
  53. package/dist/scope-observability.js +192 -0
  54. package/dist/services.js +1 -1
  55. package/dist/setup-prompt.d.ts +9 -0
  56. package/dist/setup-prompt.js +59 -0
  57. package/dist/ssrf-guard.d.ts +48 -0
  58. package/dist/ssrf-guard.js +242 -0
  59. package/dist/token-store.js +23 -125
  60. package/dist/tools/_errors.d.ts +8 -2
  61. package/dist/tools/_errors.js +97 -6
  62. package/dist/tools/account-wizard.d.ts +33 -0
  63. package/dist/tools/account-wizard.js +284 -0
  64. package/dist/tools/accounts-tool.d.ts +8 -5
  65. package/dist/tools/accounts-tool.js +38 -15
  66. package/dist/tools/admin.js +2 -2
  67. package/dist/tools/calendar.js +2 -2
  68. package/dist/tools/chat.js +2 -2
  69. package/dist/tools/contacts.d.ts +40 -0
  70. package/dist/tools/contacts.js +135 -2
  71. package/dist/tools/docs.d.ts +3 -0
  72. package/dist/tools/docs.js +60 -9
  73. package/dist/tools/drive.d.ts +3 -0
  74. package/dist/tools/drive.js +60 -8
  75. package/dist/tools/forms.js +4 -2
  76. package/dist/tools/generated/_shared.d.ts +1 -3
  77. package/dist/tools/generated/_shared.js +3 -3
  78. package/dist/tools/generated/admin.js +175 -132
  79. package/dist/tools/generated/appsmarket.js +6 -2
  80. package/dist/tools/generated/calendar.js +45 -27
  81. package/dist/tools/generated/chat.js +81 -47
  82. package/dist/tools/generated/classroom.js +131 -104
  83. package/dist/tools/generated/cloudidentity.js +73 -61
  84. package/dist/tools/generated/cloudsearch.js +59 -49
  85. package/dist/tools/generated/contacts.js +22 -14
  86. package/dist/tools/generated/drive.js +51 -35
  87. package/dist/tools/generated/driveactivity.js +5 -1
  88. package/dist/tools/generated/drivelabels.js +30 -25
  89. package/dist/tools/generated/forms.js +7 -3
  90. package/dist/tools/generated/gmail.js +73 -58
  91. package/dist/tools/generated/groupsmigration.js +5 -1
  92. package/dist/tools/generated/groupssettings.js +7 -3
  93. package/dist/tools/generated/keep.js +12 -7
  94. package/dist/tools/generated/licensing.js +11 -7
  95. package/dist/tools/generated/meet.js +20 -13
  96. package/dist/tools/generated/postmaster.js +8 -4
  97. package/dist/tools/generated/reseller.js +22 -17
  98. package/dist/tools/generated/script.js +26 -16
  99. package/dist/tools/generated/sheets.js +11 -7
  100. package/dist/tools/generated/tasks.js +6 -2
  101. package/dist/tools/generated/vault.js +38 -33
  102. package/dist/tools/generated/workspaceevents.js +12 -7
  103. package/dist/tools/gmail-mime.d.ts +38 -8
  104. package/dist/tools/gmail-mime.js +75 -71
  105. package/dist/tools/gmail.d.ts +31 -0
  106. package/dist/tools/gmail.js +483 -99
  107. package/dist/tools/google-api.js +7 -4
  108. package/dist/tools/meet.js +2 -2
  109. package/dist/tools/searchconsole.js +2 -2
  110. package/dist/tools/sheets.js +6 -2
  111. package/dist/tools/slides.js +4 -2
  112. package/dist/tools/tasks.js +2 -2
  113. package/dist/types.d.ts +1 -1
  114. package/dist/write-control.d.ts +24 -1
  115. package/dist/write-control.js +26 -1
  116. package/package.json +26 -7
@@ -0,0 +1,242 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { configDir, failStartup } from './config-file.js';
6
+ import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
7
+ import { getTokenDir } from './accounts.js';
8
+ // The keychain is a native optionalDependency: load lazily and synchronously
9
+ // (createRequire — the token store's key read is sync), and treat EVERY
10
+ // failure (no prebuild, native load error, headless Linux without a Secret
11
+ // Service) as "keychain unavailable", falling through to the 0600 file.
12
+ const require_ = createRequire(import.meta.url);
13
+ function systemKeychain(account) {
14
+ try {
15
+ const { Entry } = require_('@napi-rs/keyring');
16
+ const entry = new Entry('mcp-google-multi', account);
17
+ return {
18
+ get: () => {
19
+ try {
20
+ return entry.getPassword();
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ },
26
+ set: (value) => entry.setPassword(value),
27
+ del: () => entry.deletePassword(),
28
+ };
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ // Default keychain factory, replaceable ONLY for tests: unit tests must never
35
+ // read or write the developer's real OS keyring (the BR-5 mirror once leaked a
36
+ // test fixture key into a real GNOME keyring — never again).
37
+ let keychainFactory = systemKeychain;
38
+ export function __setKeychainFactoryForTest(fn) {
39
+ keychainFactory = fn ?? systemKeychain;
40
+ }
41
+ // Glob the token dir instead of iterating registered aliases: an orphan .enc
42
+ // (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
43
+ // one client) must still stop a fresh key from being minted.
44
+ function anyTokenFileExists() {
45
+ try {
46
+ return fs.readdirSync(getTokenDir()).some((f) => f.endsWith('.enc'));
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ function readKeyFile(filePath) {
53
+ try {
54
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
55
+ if (!raw)
56
+ return null;
57
+ try {
58
+ const mode = fs.statSync(filePath).mode & 0o777;
59
+ if (process.platform !== 'win32' && (mode & 0o077) !== 0) {
60
+ process.stderr.write(`WARN: ${filePath} is readable by others (mode ${mode.toString(8)}); chmod 600 it.\n`);
61
+ }
62
+ }
63
+ catch {
64
+ // stat raced a delete; the read already succeeded.
65
+ }
66
+ return raw;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
73
+ const env = deps.env ?? process.env;
74
+ const dir = deps.dir ?? configDir();
75
+ const keychain = deps.keychain ?? keychainFactory;
76
+ const hasAnyToken = deps.hasAnyToken ?? anyTokenFileExists;
77
+ const fail = (slug, message) => {
78
+ if (onFail === 'throw')
79
+ throw new Error(`${slug}: ${message}`);
80
+ failStartup(slug, message);
81
+ };
82
+ const fromEnv = env[envName]?.trim();
83
+ if (fromEnv)
84
+ return { key: fromEnv, provenance: 'env' };
85
+ const entry = keychain(envName);
86
+ const fromKeychain = entry?.get();
87
+ if (fromKeychain)
88
+ return { key: fromKeychain, provenance: 'keychain' };
89
+ const filePath = path.join(dir, fileName);
90
+ const fromFile = readKeyFile(filePath);
91
+ if (fromFile)
92
+ return { key: fromFile, provenance: 'file' };
93
+ // Generate-on-setup. The hard guard is cc-security Cgen: minting a fresh
94
+ // MASTER_KEY while <alias>.enc files exist would silently brick them all.
95
+ if (hardGuard && hasAnyToken()) {
96
+ fail('E_MASTER_KEY_MISSING_TOKENS_EXIST', `no ${envName} found in env, keychain or ${filePath}, but encrypted tokens exist under ${getTokenDir()}. ` +
97
+ 'Restore the original key, or delete those .enc files and re-auth each account.');
98
+ }
99
+ // Lock the generate critical section so two concurrent first-boots cannot
100
+ // both mint (the loser re-reads the winner's key under the lock).
101
+ return withFileLock(filePath, () => {
102
+ const racedKeychain = keychain(envName)?.get();
103
+ if (racedKeychain)
104
+ return { key: racedKeychain, provenance: 'keychain' };
105
+ const racedFile = readKeyFile(filePath);
106
+ if (racedFile)
107
+ return { key: racedFile, provenance: 'file' };
108
+ // Exactly randomBytes(32) base64: hits deriveKey's 32-byte fast path.
109
+ const generated = randomBytes(32).toString('base64');
110
+ // The 0600 file is ALWAYS written: on Linux the "keychain" can be the
111
+ // kernel keyutils keyring, which does not survive a reboot — a key that
112
+ // lives only there bricks every token at the next boot. The file is the
113
+ // durable sink; byte-exact read-back gates any use of the key.
114
+ atomicWriteFileSync(filePath, `${generated}\n`, 0o600);
115
+ if (readKeyFile(filePath) !== generated) {
116
+ fail('E_KEY_PROVISIONING_FAILED', `generated ${envName} could not be read back byte-exact from ${filePath}; refusing to encrypt anything with an unverified key.`);
117
+ }
118
+ // Keychain copy is best-effort QoL; a mismatched read-back would SHADOW
119
+ // the file on the next boot (keychain > file), so clean it up.
120
+ let keychainNote = '';
121
+ try {
122
+ if (entry) {
123
+ entry.set(generated);
124
+ if (keychain(envName)?.get() === generated) {
125
+ keychainNote = ' and the OS keychain (service mcp-google-multi)';
126
+ }
127
+ else {
128
+ try {
129
+ entry.del();
130
+ }
131
+ catch {
132
+ process.stderr.write(`WARN: OS keychain holds a mismatched ${envName} entry; the ${fileName} file is authoritative.\n`);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ catch {
138
+ // Keychain unavailable; the file already has the key.
139
+ }
140
+ process.stderr.write(`Generated ${envName}: stored at ${filePath} (0600)${keychainNote}.\n`);
141
+ return { key: generated, provenance: 'generated' };
142
+ }, `${envName} provisioning`);
143
+ }
144
+ let cachedMaster = null;
145
+ let cachedJwt = null;
146
+ /** BR-1 single choke point: no other module may read the env vars directly.
147
+ * Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
148
+ export function resolveMasterKey(deps) {
149
+ if (deps)
150
+ return resolveKey('MASTER_KEY', 'master.key', true, deps);
151
+ cachedMaster ??= resolveKey('MASTER_KEY', 'master.key', true, {});
152
+ return cachedMaster;
153
+ }
154
+ /** Dispatch form: identical resolution, but failures THROW (caught by the tool
155
+ * handler into an error envelope) — a live server is never process.exit'd. */
156
+ export function resolveMasterKeyForDispatch() {
157
+ cachedMaster ??= resolveKey('MASTER_KEY', 'master.key', true, {}, 'throw');
158
+ return cachedMaster;
159
+ }
160
+ /** Side-effect-free provenance probe for diagnostics: NEVER generates, never
161
+ * fires the hard guard (config check must not mint keys or exit). */
162
+ export function peekMasterKeyProvenance(deps) {
163
+ const env = deps?.env ?? process.env;
164
+ if (env.MASTER_KEY?.trim())
165
+ return 'env';
166
+ const keychain = deps?.keychain ?? keychainFactory;
167
+ if (keychain('MASTER_KEY')?.get())
168
+ return 'keychain';
169
+ const dir = deps?.dir ?? configDir();
170
+ if (readKeyFile(path.join(dir, 'master.key')))
171
+ return 'file';
172
+ return 'unprovisioned';
173
+ }
174
+ /** MCP_JWT_KEY variant: NO hard guard — regenerating only invalidates
175
+ * outstanding MCP tokens and clients re-auth silently. */
176
+ export function resolveJwtKey(deps) {
177
+ if (deps)
178
+ return resolveKey('MCP_JWT_KEY', 'mcp-jwt.key', false, deps);
179
+ cachedJwt ??= resolveKey('MCP_JWT_KEY', 'mcp-jwt.key', false, {});
180
+ return cachedJwt;
181
+ }
182
+ let mirrored = false;
183
+ /**
184
+ * BR-5: after the FIRST successful token decrypt with an env-sourced key,
185
+ * mirror it into the keychain (only when the keychain holds no entry) so the
186
+ * key survives an env loss. Gating on a successful decrypt means a stale or
187
+ * wrong env key is never mirrored. Best-effort: failures are doctor material,
188
+ * never fatal.
189
+ */
190
+ export function noteSuccessfulDecrypt() {
191
+ if (mirrored)
192
+ return;
193
+ mirrored = true;
194
+ try {
195
+ if (cachedMaster?.provenance !== 'env')
196
+ return;
197
+ const entry = keychainFactory('MASTER_KEY');
198
+ if (!entry || entry.get())
199
+ return;
200
+ entry.set(cachedMaster.key);
201
+ }
202
+ catch {
203
+ // Keychain backend unavailable; env stays the working source.
204
+ }
205
+ }
206
+ /** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
207
+ * entry) so the next run re-provisions. Never removes an env-provided key (the
208
+ * deployer owns that; `env:true` flags it so reset can warn). The caller MUST
209
+ * have wiped every .enc token first — regenerating with tokens present would
210
+ * brick them (enforced by the reset planner). */
211
+ export function deleteMasterKeyMaterial(deps) {
212
+ const dir = deps?.dir ?? configDir();
213
+ const keychain = deps?.keychain ?? keychainFactory;
214
+ const env = Boolean(process.env.MASTER_KEY?.trim());
215
+ let file = false;
216
+ let kc = false;
217
+ try {
218
+ fs.unlinkSync(path.join(dir, 'master.key'));
219
+ file = true;
220
+ }
221
+ catch {
222
+ // already absent
223
+ }
224
+ try {
225
+ const entry = keychain('MASTER_KEY');
226
+ if (entry && entry.get()) {
227
+ entry.del();
228
+ kc = true;
229
+ }
230
+ }
231
+ catch {
232
+ // keychain unavailable
233
+ }
234
+ cachedMaster = null;
235
+ return { file, keychain: kc, env };
236
+ }
237
+ /** Test hook: the production cache is process-lifetime by design. */
238
+ export function clearKeyCacheForTest() {
239
+ cachedMaster = null;
240
+ cachedJwt = null;
241
+ mirrored = false;
242
+ }
@@ -0,0 +1,81 @@
1
+ export declare const ACCESS_TTL_DEFAULT = 600;
2
+ export declare const STATE_TTL_DEFAULT = 600;
3
+ export declare const CODE_TTL_DEFAULT = 60;
4
+ export declare const REPLAY_CAP_DEFAULT = 10000;
5
+ /** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
6
+ export declare function jwtSecretFrom(jwtKey: string): Uint8Array;
7
+ export interface AccessTokenParams {
8
+ base: string;
9
+ secret: Uint8Array;
10
+ ttlSec?: number;
11
+ iat: number;
12
+ }
13
+ export declare function signAccessToken(p: AccessTokenParams): Promise<string>;
14
+ export interface AccessClaims {
15
+ sub: string;
16
+ scope: string;
17
+ jti: string;
18
+ }
19
+ /** Verify an access token for `/mcp`. Throws on bad sig / aud / iss / exp. */
20
+ export declare function verifyAccessToken(token: string, base: string, secret: Uint8Array): Promise<AccessClaims>;
21
+ export interface StatePayload {
22
+ flow: 'owner_gate' | 'alias_reauth';
23
+ client_id: string;
24
+ redirect_uri: string;
25
+ code_challenge: string;
26
+ client_state?: string;
27
+ resource: string;
28
+ alias?: string;
29
+ }
30
+ export interface CodePayload {
31
+ redirect_uri: string;
32
+ code_challenge: string;
33
+ resource: string;
34
+ sub: 'owner';
35
+ }
36
+ export declare function signState(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
37
+ export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
38
+ jti: string;
39
+ }>;
40
+ /** A pending-authorization artifact for the DCR consent interstitial (same
41
+ * shape as `state`, distinct purpose so the two can't be confused). */
42
+ export declare function signPending(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
43
+ export declare function verifyPending(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
44
+ jti: string;
45
+ }>;
46
+ export declare function signAuthzCode(payload: CodePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
47
+ export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array): Promise<CodePayload & {
48
+ jti: string;
49
+ }>;
50
+ export declare class ReplayGuard {
51
+ private readonly cap;
52
+ private readonly seen;
53
+ constructor(cap?: number);
54
+ /** Record a jti as spent. Returns false if it was already spent (replay). */
55
+ consume(jti: string, ttlMs: number, nowMs: number): boolean;
56
+ get size(): number;
57
+ private evictExpired;
58
+ }
59
+ export interface RefreshRecord {
60
+ sub: 'owner';
61
+ issuedAt: number;
62
+ family: string;
63
+ }
64
+ /**
65
+ * Persisted (encrypted under MASTER_KEY) opaque refresh-token store. Rotates on
66
+ * every use (a stolen token is usable at most once), and detects REUSE of a
67
+ * rotated-away token as theft: the whole token family is revoked (#7 / C14).
68
+ * All read-modify-write goes through a file lock so concurrent stdio+HTTP
69
+ * processes can't lost-update or double-spend (#12).
70
+ */
71
+ export declare class RefreshStore {
72
+ private readonly path;
73
+ private readonly masterKey;
74
+ constructor(path: string, masterKey: string);
75
+ private load;
76
+ private save;
77
+ issue(nowMs: number, family?: string): string;
78
+ /** Rotate a presented refresh token; null if unknown OR if the presented
79
+ * token was already rotated away (reuse => the family is revoked). */
80
+ rotate(oldToken: string, nowMs: number): string | null;
81
+ }
@@ -0,0 +1,176 @@
1
+ // B13 token model (oauth-authorization-server.md §Data model): the MCP access
2
+ // token (HS256 JWT, jose-only — AS==RS so no JWKS), the signed self-contained
3
+ // `state` and authorization code, the in-memory single-use replay guard, and
4
+ // the opaque rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
5
+ // (B5), so tokens survive a restart as long as the key persists.
6
+ import { randomBytes } from 'node:crypto';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { SignJWT, jwtVerify } from 'jose';
9
+ import { deriveKey, encryptToken, decryptToken } from './token-store.js';
10
+ import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
11
+ export const ACCESS_TTL_DEFAULT = 600; // seconds
12
+ export const STATE_TTL_DEFAULT = 600;
13
+ export const CODE_TTL_DEFAULT = 60;
14
+ export const REPLAY_CAP_DEFAULT = 10_000;
15
+ /** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
16
+ export function jwtSecretFrom(jwtKey) {
17
+ return new Uint8Array(deriveKey(jwtKey));
18
+ }
19
+ function newJti() {
20
+ return randomBytes(16).toString('base64url');
21
+ }
22
+ export async function signAccessToken(p) {
23
+ const ttl = p.ttlSec ?? ACCESS_TTL_DEFAULT;
24
+ return new SignJWT({ scope: 'mcp:use', purpose: 'mcp_access' })
25
+ .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
26
+ .setIssuer(p.base)
27
+ .setSubject('owner')
28
+ .setAudience(`${p.base}/mcp`)
29
+ .setIssuedAt(p.iat)
30
+ .setExpirationTime(p.iat + ttl)
31
+ .setJti(newJti())
32
+ .sign(p.secret);
33
+ }
34
+ /** Verify an access token for `/mcp`. Throws on bad sig / aud / iss / exp. */
35
+ export async function verifyAccessToken(token, base, secret) {
36
+ const { payload } = await jwtVerify(token, secret, { issuer: base, audience: `${base}/mcp` });
37
+ if (payload.purpose !== 'mcp_access')
38
+ throw new Error('wrong token purpose');
39
+ return { sub: String(payload.sub), scope: String(payload.scope ?? ''), jti: String(payload.jti ?? '') };
40
+ }
41
+ async function signArtifact(claims, purpose, base, secret, iat, ttlSec) {
42
+ return new SignJWT({ ...claims, purpose })
43
+ .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
44
+ .setIssuer(base)
45
+ .setIssuedAt(iat)
46
+ .setExpirationTime(iat + ttlSec)
47
+ .setJti(newJti())
48
+ .sign(secret);
49
+ }
50
+ async function verifyArtifact(token, purpose, base, secret) {
51
+ const { payload } = await jwtVerify(token, secret, { issuer: base });
52
+ if (payload.purpose !== purpose)
53
+ throw new Error(`wrong artifact purpose (${String(payload.purpose)})`);
54
+ return { ...payload, jti: String(payload.jti ?? '') };
55
+ }
56
+ export function signState(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
57
+ return signArtifact(payload, 'mcp_state', base, secret, iat, ttlSec);
58
+ }
59
+ export async function verifyState(token, base, secret) {
60
+ return (await verifyArtifact(token, 'mcp_state', base, secret));
61
+ }
62
+ /** A pending-authorization artifact for the DCR consent interstitial (same
63
+ * shape as `state`, distinct purpose so the two can't be confused). */
64
+ export function signPending(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
65
+ return signArtifact(payload, 'mcp_pending', base, secret, iat, ttlSec);
66
+ }
67
+ export async function verifyPending(token, base, secret) {
68
+ return (await verifyArtifact(token, 'mcp_pending', base, secret));
69
+ }
70
+ export function signAuthzCode(payload, base, secret, iat, ttlSec = CODE_TTL_DEFAULT) {
71
+ return signArtifact(payload, 'mcp_code', base, secret, iat, ttlSec);
72
+ }
73
+ export async function verifyAuthzCode(token, base, secret) {
74
+ return (await verifyArtifact(token, 'mcp_code', base, secret));
75
+ }
76
+ // --- Replay guard (C10/C17: single-use, capped, TTL-evicted) ----------------
77
+ export class ReplayGuard {
78
+ cap;
79
+ seen = new Map(); // jti -> expiry (ms)
80
+ constructor(cap = REPLAY_CAP_DEFAULT) {
81
+ this.cap = cap;
82
+ }
83
+ /** Record a jti as spent. Returns false if it was already spent (replay). */
84
+ consume(jti, ttlMs, nowMs) {
85
+ this.evictExpired(nowMs);
86
+ if (this.seen.has(jti))
87
+ return false;
88
+ if (this.seen.size >= this.cap) {
89
+ // drop the oldest-inserted entry to bound memory (C17)
90
+ const oldest = this.seen.keys().next().value;
91
+ if (oldest !== undefined)
92
+ this.seen.delete(oldest);
93
+ }
94
+ this.seen.set(jti, nowMs + ttlMs);
95
+ return true;
96
+ }
97
+ get size() {
98
+ return this.seen.size;
99
+ }
100
+ evictExpired(nowMs) {
101
+ for (const [jti, exp] of this.seen) {
102
+ if (exp <= nowMs)
103
+ this.seen.delete(jti);
104
+ }
105
+ }
106
+ }
107
+ const SPENT_CAP = 2000;
108
+ /**
109
+ * Persisted (encrypted under MASTER_KEY) opaque refresh-token store. Rotates on
110
+ * every use (a stolen token is usable at most once), and detects REUSE of a
111
+ * rotated-away token as theft: the whole token family is revoked (#7 / C14).
112
+ * All read-modify-write goes through a file lock so concurrent stdio+HTTP
113
+ * processes can't lost-update or double-spend (#12).
114
+ */
115
+ export class RefreshStore {
116
+ path;
117
+ masterKey;
118
+ constructor(path, masterKey) {
119
+ this.path = path;
120
+ this.masterKey = masterKey;
121
+ }
122
+ load() {
123
+ if (!existsSync(this.path))
124
+ return { active: {}, spent: {} };
125
+ try {
126
+ const d = decryptToken(readFileSync(this.path, 'utf-8'), this.masterKey);
127
+ return { active: d.active ?? {}, spent: d.spent ?? {} };
128
+ }
129
+ catch {
130
+ return { active: {}, spent: {} };
131
+ }
132
+ }
133
+ save(data) {
134
+ // bound the spent set (drop oldest-inserted) so it can't grow forever.
135
+ const keys = Object.keys(data.spent);
136
+ if (keys.length > SPENT_CAP) {
137
+ for (const k of keys.slice(0, keys.length - SPENT_CAP))
138
+ delete data.spent[k];
139
+ }
140
+ atomicWriteFileSync(this.path, encryptToken(data, this.masterKey), 0o600);
141
+ }
142
+ issue(nowMs, family) {
143
+ return withFileLock(this.path, () => {
144
+ const token = randomBytes(32).toString('base64url');
145
+ const data = this.load();
146
+ data.active[token] = { sub: 'owner', issuedAt: nowMs, family: family ?? randomBytes(12).toString('hex') };
147
+ this.save(data);
148
+ return token;
149
+ });
150
+ }
151
+ /** Rotate a presented refresh token; null if unknown OR if the presented
152
+ * token was already rotated away (reuse => the family is revoked). */
153
+ rotate(oldToken, nowMs) {
154
+ return withFileLock(this.path, () => {
155
+ const data = this.load();
156
+ const rec = data.active[oldToken];
157
+ if (!rec) {
158
+ // Reuse of a rotated-away token signals theft: revoke the whole family.
159
+ const fam = data.spent[oldToken];
160
+ if (fam) {
161
+ for (const [t, r] of Object.entries(data.active))
162
+ if (r.family === fam)
163
+ delete data.active[t];
164
+ this.save(data);
165
+ }
166
+ return null;
167
+ }
168
+ delete data.active[oldToken];
169
+ data.spent[oldToken] = rec.family;
170
+ const next = randomBytes(32).toString('base64url');
171
+ data.active[next] = { sub: 'owner', issuedAt: nowMs, family: rec.family };
172
+ this.save(data);
173
+ return next;
174
+ });
175
+ }
176
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Synthesize config.json accounts{} from the v5 env (GOOGLE_ACCOUNTS +
3
+ * GOOGLE_ADMIN_ACCOUNTS). Never deletes or edits env; idempotent (a re-run
4
+ * that changes nothing says so). Scope-profile and preference fields are
5
+ * migrated by their own slices' commands.
6
+ */
7
+ export declare function runMigrateConfig(env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,103 @@
1
+ import { CONFIG_VERSION, configFilePath, loadConfigFile, mutateConfigFile } from './config-file.js';
2
+ import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
3
+ function parseCsv(value) {
4
+ return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
5
+ }
6
+ /**
7
+ * Synthesize config.json accounts{} from the v5 env (GOOGLE_ACCOUNTS +
8
+ * GOOGLE_ADMIN_ACCOUNTS). Never deletes or edits env; idempotent (a re-run
9
+ * that changes nothing says so). Scope-profile and preference fields are
10
+ * migrated by their own slices' commands.
11
+ */
12
+ export function runMigrateConfig(env = process.env) {
13
+ const raw = env.GOOGLE_ACCOUNTS;
14
+ if (!raw || raw.trim() === '') {
15
+ console.log('GOOGLE_ACCOUNTS is not set — nothing to migrate.');
16
+ return;
17
+ }
18
+ const adminAliases = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
19
+ const desired = {};
20
+ const entries = raw.split(',');
21
+ for (let i = 0; i < entries.length; i++) {
22
+ const trimmed = entries[i].trim();
23
+ if (!trimmed)
24
+ continue;
25
+ const colonIdx = trimmed.indexOf(':');
26
+ if (colonIdx === -1) {
27
+ console.error(`Skipping GOOGLE_ACCOUNTS entry ${i + 1}: expected alias:email.`);
28
+ continue;
29
+ }
30
+ const alias = trimmed.slice(0, colonIdx).trim();
31
+ const email = trimmed.slice(colonIdx + 1).trim();
32
+ if (!alias || !email || !/^[a-zA-Z0-9_-]+$/.test(alias)) {
33
+ console.error(`Skipping GOOGLE_ACCOUNTS entry ${i + 1}: empty or invalid alias/email.`);
34
+ continue;
35
+ }
36
+ desired[alias] = { email };
37
+ }
38
+ // Legacy global scopes (BC7) become an explicit named profile every account
39
+ // points at, so removing the env var later changes nothing.
40
+ const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
41
+ let legacyProfile;
42
+ if (legacyNames.length > 0) {
43
+ const bundles = resolveBundleAliases(legacyNames);
44
+ for (const bundle of bundles) {
45
+ if (bundle === 'admin') {
46
+ console.error('E_UNKNOWN_BUNDLE: "admin" is not a global bundle: use GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile. Nothing written.');
47
+ process.exitCode = 1;
48
+ return;
49
+ }
50
+ if (!(bundle in BUNDLE_CATALOG)) {
51
+ const hint = closestBundle(bundle);
52
+ console.error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}. Nothing written.`);
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ }
57
+ legacyProfile = { bundles };
58
+ }
59
+ const filePath = configFilePath(env);
60
+ const before = loadConfigFile(filePath);
61
+ // Merge per-alias: env owns the alias set and emails, but file-only fields
62
+ // (scopeProfile, admin) survive for retained aliases — mirroring runtime
63
+ // precedence, where unset GOOGLE_ADMIN_ACCOUNTS preserves file admin flags.
64
+ const existing = before?.accounts ?? {};
65
+ const dropped = Object.keys(existing).filter((a) => !(a in desired));
66
+ for (const [alias, entry] of Object.entries(desired)) {
67
+ const prev = existing[alias];
68
+ if (legacyProfile)
69
+ entry.scopeProfile = 'legacy-global';
70
+ else if (prev?.scopeProfile)
71
+ entry.scopeProfile = prev.scopeProfile;
72
+ if (adminAliases.length > 0) {
73
+ if (adminAliases.includes(alias))
74
+ entry.admin = true;
75
+ }
76
+ else if (prev?.admin) {
77
+ entry.admin = true;
78
+ }
79
+ }
80
+ const beforeAccounts = JSON.stringify(existing);
81
+ const beforeProfiles = JSON.stringify(before?.scopeProfiles ?? {});
82
+ const desiredProfiles = legacyProfile
83
+ ? { ...(before?.scopeProfiles ?? {}), 'legacy-global': legacyProfile }
84
+ : (before?.scopeProfiles ?? {});
85
+ if (beforeAccounts === JSON.stringify(desired) && beforeProfiles === JSON.stringify(desiredProfiles)) {
86
+ console.log(`${filePath} already matches the environment — nothing to do.`);
87
+ return;
88
+ }
89
+ mutateConfigFile((current) => {
90
+ current.version = current.version || CONFIG_VERSION;
91
+ current.accounts = desired;
92
+ if (legacyProfile)
93
+ current.scopeProfiles = desiredProfiles;
94
+ return current;
95
+ }, filePath);
96
+ if (dropped.length > 0) {
97
+ console.error(`Dropping ${dropped.length} alias(es) present in config.json but absent from GOOGLE_ACCOUNTS.`);
98
+ }
99
+ console.log(`Wrote ${filePath}:`);
100
+ console.log(` accounts before: ${beforeAccounts}`);
101
+ console.log(` accounts after: ${JSON.stringify(desired)}`);
102
+ console.log('Environment variables were NOT modified; while GOOGLE_ACCOUNTS is set, env still wins.');
103
+ }
@@ -1,11 +1,12 @@
1
1
  import fs from 'node:fs';
2
- import { ACCOUNTS, ACCOUNT_CONFIG } from './accounts.js';
2
+ import { getAccountSet } from './accounts.js';
3
3
  import { writeToken, hasToken } from './token-store.js';
4
4
  export function runMigrateTokens() {
5
5
  let migrated = 0;
6
6
  let skipped = 0;
7
- for (const alias of ACCOUNTS) {
8
- const plain = ACCOUNT_CONFIG[alias].tokenPath;
7
+ const { aliases, configs } = getAccountSet();
8
+ for (const alias of aliases) {
9
+ const plain = configs[alias].tokenPath;
9
10
  if (!fs.existsSync(plain))
10
11
  continue;
11
12
  if (hasToken(alias)) {
@@ -0,0 +1,8 @@
1
+ export declare const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
2
+ /** Pure decision: the timeout to apply, or null to leave Node's setting alone. */
3
+ export declare function decideConnectAttemptTimeout(opts: {
4
+ execArgv: readonly string[];
5
+ nodeOptions: string | undefined;
6
+ current: number;
7
+ }): number | null;
8
+ export declare function applyNetTuning(): void;
@@ -0,0 +1,25 @@
1
+ import net from 'node:net';
2
+ // Node's happy-eyeballs gives each address family 250ms per connect attempt on
3
+ // every LTS line (raised to 500ms only in v25.2+); on high-latency or
4
+ // broken-IPv6 links that aborts EVERY Google call while curl works. Raise the
5
+ // process default unless the user tuned it themselves. Why: docs/internals.md.
6
+ export const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
7
+ const USER_FLAGS = ['--network-family-autoselection-attempt-timeout', '--no-network-family-autoselection'];
8
+ /** Pure decision: the timeout to apply, or null to leave Node's setting alone. */
9
+ export function decideConnectAttemptTimeout(opts) {
10
+ const userArgs = [...opts.execArgv, opts.nodeOptions ?? ''].join(' ');
11
+ if (USER_FLAGS.some((f) => userArgs.includes(f)))
12
+ return null;
13
+ if (opts.current >= CONNECT_ATTEMPT_TIMEOUT_MS)
14
+ return null;
15
+ return CONNECT_ATTEMPT_TIMEOUT_MS;
16
+ }
17
+ export function applyNetTuning() {
18
+ const timeout = decideConnectAttemptTimeout({
19
+ execArgv: process.execArgv,
20
+ nodeOptions: process.env.NODE_OPTIONS,
21
+ current: net.getDefaultAutoSelectFamilyAttemptTimeout(),
22
+ });
23
+ if (timeout !== null)
24
+ net.setDefaultAutoSelectFamilyAttemptTimeout(timeout);
25
+ }