mcp-google-multi 6.0.0-beta.1 → 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.
- package/README.md +5 -3
- package/dist/accounts.d.ts +22 -1
- package/dist/accounts.js +64 -16
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +23 -15
- package/dist/arg-normalize.d.ts +61 -4
- package/dist/arg-normalize.js +229 -4
- package/dist/arg-strict.d.ts +61 -0
- package/dist/arg-strict.js +193 -0
- package/dist/auth.d.ts +4 -3
- package/dist/auth.js +26 -85
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +38 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +117 -0
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +11 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +14 -0
- package/dist/config-file.js +41 -1
- package/dist/discover.js +38 -15
- package/dist/discovery-client.d.ts +11 -1
- package/dist/discovery-client.js +120 -18
- package/dist/doctor.d.ts +15 -3
- package/dist/doctor.js +151 -27
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +16 -3
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +83 -14
- package/dist/fs-atomic.d.ts +11 -0
- package/dist/fs-atomic.js +219 -28
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +64 -0
- package/dist/http-transport.d.ts +73 -6
- package/dist/http-transport.js +350 -26
- package/dist/identity.d.ts +4 -1
- package/dist/identity.js +25 -6
- package/dist/index.js +142 -114
- package/dist/master-key.js +13 -5
- package/dist/mcp-token.d.ts +136 -19
- package/dist/mcp-token.js +569 -72
- package/dist/metrics-cli.d.ts +21 -0
- package/dist/metrics-cli.js +206 -0
- package/dist/metrics-tap.d.ts +12 -0
- package/dist/metrics-tap.js +95 -0
- package/dist/migrate-config.js +2 -2
- package/dist/oauth-as.d.ts +103 -3
- package/dist/oauth-as.js +374 -79
- package/dist/oauth-consent.d.ts +35 -10
- package/dist/oauth-consent.js +101 -39
- package/dist/outbound-allowlist.d.ts +34 -0
- package/dist/outbound-allowlist.js +111 -0
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +12 -8
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +65 -5
- package/dist/registry.d.ts +66 -2
- package/dist/registry.js +214 -18
- package/dist/scope-catalog.d.ts +11 -0
- package/dist/scope-catalog.js +39 -6
- package/dist/scope-observability.d.ts +8 -6
- package/dist/scope-observability.js +29 -13
- package/dist/services.d.ts +17 -4
- package/dist/services.js +77 -6
- package/dist/setup-prompt.d.ts +1 -1
- package/dist/setup-prompt.js +1 -1
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +101 -62
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +67 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +5 -0
- package/dist/tools/_coerce.js +11 -0
- package/dist/tools/_errors.d.ts +34 -3
- package/dist/tools/_errors.js +346 -53
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +25 -2
- package/dist/tools/account-wizard.d.ts +34 -2
- package/dist/tools/account-wizard.js +223 -65
- package/dist/tools/accounts-tool.d.ts +7 -0
- package/dist/tools/accounts-tool.js +34 -18
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +21 -16
- package/dist/tools/analytics.d.ts +27 -0
- package/dist/tools/analytics.js +289 -0
- package/dist/tools/calendar.d.ts +9 -1
- package/dist/tools/calendar.js +93 -29
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +20 -12
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +74 -43
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +90 -84
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +197 -120
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +26 -19
- package/dist/tools/generated/_shared.d.ts +8 -1
- package/dist/tools/generated/_shared.js +18 -4
- package/dist/tools/generated/admin.d.ts +2 -1
- package/dist/tools/generated/admin.js +610 -479
- package/dist/tools/generated/analytics.d.ts +3 -0
- package/dist/tools/generated/analytics.js +981 -0
- package/dist/tools/generated/appsmarket.d.ts +2 -1
- package/dist/tools/generated/appsmarket.js +9 -9
- package/dist/tools/generated/calendar.d.ts +2 -1
- package/dist/tools/generated/calendar.js +83 -83
- package/dist/tools/generated/chat.d.ts +2 -1
- package/dist/tools/generated/chat.js +178 -151
- package/dist/tools/generated/classroom.d.ts +2 -1
- package/dist/tools/generated/classroom.js +467 -425
- package/dist/tools/generated/cloudidentity.d.ts +2 -1
- package/dist/tools/generated/cloudidentity.js +209 -201
- package/dist/tools/generated/cloudsearch.d.ts +2 -1
- package/dist/tools/generated/cloudsearch.js +139 -138
- package/dist/tools/generated/contacts.d.ts +2 -1
- package/dist/tools/generated/contacts.js +49 -40
- package/dist/tools/generated/drive.d.ts +2 -1
- package/dist/tools/generated/drive.js +133 -124
- package/dist/tools/generated/driveactivity.d.ts +2 -1
- package/dist/tools/generated/driveactivity.js +3 -3
- package/dist/tools/generated/drivelabels.d.ts +2 -1
- package/dist/tools/generated/drivelabels.js +113 -85
- package/dist/tools/generated/forms.d.ts +2 -1
- package/dist/tools/generated/forms.js +14 -14
- package/dist/tools/generated/gmail.d.ts +2 -1
- package/dist/tools/generated/gmail.js +253 -226
- package/dist/tools/generated/groupsmigration.d.ts +2 -1
- package/dist/tools/generated/groupsmigration.js +4 -4
- package/dist/tools/generated/groupssettings.d.ts +2 -1
- package/dist/tools/generated/groupssettings.js +10 -10
- package/dist/tools/generated/index.d.ts +2 -1
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/generated/keep.d.ts +2 -1
- package/dist/tools/generated/keep.js +23 -22
- package/dist/tools/generated/licensing.d.ts +2 -1
- package/dist/tools/generated/licensing.js +54 -38
- package/dist/tools/generated/meet.d.ts +2 -1
- package/dist/tools/generated/meet.js +41 -41
- package/dist/tools/generated/method-map.d.ts +2 -0
- package/dist/tools/generated/method-map.js +894 -0
- package/dist/tools/generated/postmaster.d.ts +2 -1
- package/dist/tools/generated/postmaster.js +12 -12
- package/dist/tools/generated/reseller.d.ts +2 -1
- package/dist/tools/generated/reseller.js +69 -63
- package/dist/tools/generated/script.d.ts +2 -1
- package/dist/tools/generated/script.js +62 -52
- package/dist/tools/generated/searchconsole.d.ts +2 -1
- package/dist/tools/generated/searchconsole.js +8 -6
- package/dist/tools/generated/sheets.d.ts +2 -1
- package/dist/tools/generated/sheets.js +25 -24
- package/dist/tools/generated/tasks.d.ts +2 -1
- package/dist/tools/generated/tasks.js +15 -9
- package/dist/tools/generated/vault.d.ts +2 -1
- package/dist/tools/generated/vault.js +132 -123
- package/dist/tools/generated/workspaceevents.d.ts +2 -1
- package/dist/tools/generated/workspaceevents.js +48 -47
- package/dist/tools/gmail.d.ts +12 -2
- package/dist/tools/gmail.js +95 -51
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +76 -33
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +18 -11
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +27 -23
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +72 -80
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +23 -16
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +35 -31
- package/dist/trim.d.ts +26 -0
- package/dist/trim.js +67 -0
- package/dist/usage-metrics.d.ts +178 -0
- package/dist/usage-metrics.js +621 -0
- package/dist/write-control.d.ts +27 -1
- package/dist/write-control.js +99 -12
- package/package.json +34 -3
package/dist/auth.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
-
import http from 'node:http';
|
|
3
|
-
import { URL } from 'node:url';
|
|
4
1
|
import { randomBytes } from 'node:crypto';
|
|
5
2
|
import { openUrl } from './open-url.js';
|
|
6
3
|
import { ACCOUNTS, getAccountSet } from './accounts.js';
|
|
7
|
-
import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
4
|
+
import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
8
5
|
import { resolveMasterKey } from './master-key.js';
|
|
9
6
|
import { writeToken } from './token-store.js';
|
|
7
|
+
import { buildConsentClient, openLoopbackConsent, TESTING_MODE_WARNING } from './oauth-consent.js';
|
|
10
8
|
// Personal (non-Workspace) accounts 403 on admin scopes; ADMIN_SCOPES stays per-account opt-in, never granted by default.
|
|
11
9
|
export const BASE_SCOPES = [
|
|
12
10
|
'https://www.googleapis.com/auth/gmail.modify',
|
|
@@ -50,18 +48,17 @@ function legacyGlobalProfile() {
|
|
|
50
48
|
if (bundle === 'admin') {
|
|
51
49
|
throw new Error('E_UNKNOWN_BUNDLE: "admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
|
|
52
50
|
}
|
|
53
|
-
if (!(bundle
|
|
51
|
+
if (!isKnownBundle(bundle)) {
|
|
54
52
|
const hint = closestBundle(bundle);
|
|
55
53
|
throw new Error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
|
|
56
54
|
}
|
|
57
55
|
}
|
|
58
56
|
return { bundles };
|
|
59
57
|
}
|
|
60
|
-
function profileForAccount(alias) {
|
|
58
|
+
function profileForAccount(alias, set = getAccountSet()) {
|
|
61
59
|
const legacy = legacyGlobalProfile();
|
|
62
60
|
if (legacy)
|
|
63
61
|
return legacy;
|
|
64
|
-
const set = getAccountSet();
|
|
65
62
|
const name = set.configs[alias]?.scopeProfile ?? 'base';
|
|
66
63
|
// hasOwn: a profile named like an Object.prototype member must never
|
|
67
64
|
// resolve to the inherited function.
|
|
@@ -69,14 +66,13 @@ function profileForAccount(alias) {
|
|
|
69
66
|
}
|
|
70
67
|
/** Union of every account's resolved bundles: a service registers if ANY
|
|
71
68
|
* account can authorize it; per-account authz happens at call time (BR2). */
|
|
72
|
-
export function getOptionalBundles() {
|
|
69
|
+
export function getOptionalBundles(set = getAccountSet()) {
|
|
73
70
|
const legacy = legacyGlobalProfile();
|
|
74
71
|
if (legacy)
|
|
75
72
|
return legacy.bundles.filter(b => b !== 'admin');
|
|
76
73
|
const union = new Set();
|
|
77
|
-
const set = getAccountSet();
|
|
78
74
|
for (const alias of set.aliases) {
|
|
79
|
-
for (const b of profileForAccount(alias).bundles) {
|
|
75
|
+
for (const b of profileForAccount(alias, set).bundles) {
|
|
80
76
|
if (b !== 'admin')
|
|
81
77
|
union.add(b);
|
|
82
78
|
}
|
|
@@ -86,12 +82,12 @@ export function getOptionalBundles() {
|
|
|
86
82
|
/** Aliases granted ADMIN_SCOPES: per-account admin flag (env
|
|
87
83
|
* GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
|
|
88
84
|
* scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
|
|
89
|
-
export function getAdminAccounts() {
|
|
90
|
-
const { aliases, configs } =
|
|
85
|
+
export function getAdminAccounts(set = getAccountSet()) {
|
|
86
|
+
const { aliases, configs } = set;
|
|
91
87
|
return aliases.filter((a) => {
|
|
92
88
|
if (configs[a].admin === true)
|
|
93
89
|
return true;
|
|
94
|
-
const p = profileForAccount(a);
|
|
90
|
+
const p = profileForAccount(a, set);
|
|
95
91
|
return p.admin === true || p.bundles.includes('admin');
|
|
96
92
|
});
|
|
97
93
|
}
|
|
@@ -99,15 +95,15 @@ export function getAdminAccounts() {
|
|
|
99
95
|
* legacy env) changes its consent set and requires re-running auth. Evaluated
|
|
100
96
|
* per account: `work` can carry admin + gmail_settings while `personal` is
|
|
101
97
|
* never asked for them. */
|
|
102
|
-
export function resolveScopesForAccount(alias) {
|
|
103
|
-
const profile = profileForAccount(alias);
|
|
98
|
+
export function resolveScopesForAccount(alias, set = getAccountSet()) {
|
|
99
|
+
const profile = profileForAccount(alias, set);
|
|
104
100
|
const scopes = profile.includesBase === false ? [] : [...BASE_SCOPES];
|
|
105
101
|
for (const bundle of profile.bundles) {
|
|
106
102
|
if (bundle === 'admin')
|
|
107
103
|
continue;
|
|
108
104
|
scopes.push(...BUNDLE_CATALOG[bundle].scopes);
|
|
109
105
|
}
|
|
110
|
-
if (getAdminAccounts().includes(alias)) {
|
|
106
|
+
if (getAdminAccounts(set).includes(alias)) {
|
|
111
107
|
scopes.push(...ADMIN_SCOPES);
|
|
112
108
|
}
|
|
113
109
|
return Array.from(new Set(scopes));
|
|
@@ -134,7 +130,11 @@ export async function runAuthFlow(args) {
|
|
|
134
130
|
// Auto-provisions on a fresh install (env > keychain > file > generate);
|
|
135
131
|
// resolves eagerly so a provisioning failure surfaces before the browser opens.
|
|
136
132
|
resolveMasterKey();
|
|
137
|
-
|
|
133
|
+
// Shared ephemeral-port loopback flow (oauth-consent.ts): listen first, then
|
|
134
|
+
// build the auth URL from the assigned redirect. CLI gets a patient timeout;
|
|
135
|
+
// errors propagate to main()'s fatal handler like every other CLI failure.
|
|
136
|
+
const loop = await openLoopbackConsent({ timeoutMs: 10 * 60_000 });
|
|
137
|
+
const oauth2Client = buildConsentClient(loop.redirect);
|
|
138
138
|
// CSRF protection for the OAuth callback (RFC 6749 §10.12).
|
|
139
139
|
const expectedState = randomBytes(32).toString('hex');
|
|
140
140
|
const authorizeUrl = oauth2Client.generateAuthUrl({
|
|
@@ -149,72 +149,13 @@ export async function runAuthFlow(args) {
|
|
|
149
149
|
if (getAdminAccounts().includes(alias)) {
|
|
150
150
|
console.log(' ⚠ Admin scopes included — this account will be granted Workspace admin access.');
|
|
151
151
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
162
|
-
res.end(`Authorization denied: ${error}`);
|
|
163
|
-
server.close();
|
|
164
|
-
server.closeAllConnections();
|
|
165
|
-
reject(new Error(`Authorization denied: ${error}`));
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
const code = qs.get('code');
|
|
169
|
-
if (!code) {
|
|
170
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
171
|
-
res.end('No authorization code received.');
|
|
172
|
-
server.close();
|
|
173
|
-
server.closeAllConnections();
|
|
174
|
-
reject(new Error('No authorization code received'));
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
const returnedState = qs.get('state');
|
|
178
|
-
if (returnedState !== expectedState) {
|
|
179
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
180
|
-
res.end('State mismatch — possible CSRF attempt. Aborting.');
|
|
181
|
-
server.close();
|
|
182
|
-
server.closeAllConnections();
|
|
183
|
-
reject(new Error('OAuth state token mismatch'));
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
const { tokens } = await oauth2Client.getToken(code);
|
|
187
|
-
writeToken(alias, tokens);
|
|
188
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
189
|
-
res.end('<h2>Authentication successful!</h2><p>You can close this tab.</p>');
|
|
190
|
-
server.close();
|
|
191
|
-
server.closeAllConnections();
|
|
192
|
-
console.log(`Token saved (encrypted) for ${alias}.`);
|
|
193
|
-
console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
|
|
194
|
-
resolve();
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
catch (e) {
|
|
198
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
199
|
-
res.end('Internal error during authentication.');
|
|
200
|
-
server.close();
|
|
201
|
-
server.closeAllConnections();
|
|
202
|
-
reject(e);
|
|
203
|
-
}
|
|
204
|
-
})
|
|
205
|
-
// Bind to loopback only — never expose the OAuth callback to the local network.
|
|
206
|
-
.listen(4242, '127.0.0.1', () => {
|
|
207
|
-
// Always print the URL first: the browser launch is best-effort and
|
|
208
|
-
// silently does nothing on headless/SSH sessions.
|
|
209
|
-
console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
|
|
210
|
-
openUrl(authorizeUrl);
|
|
211
|
-
});
|
|
212
|
-
server.on('error', (err) => {
|
|
213
|
-
if (err.code === 'EADDRINUSE') {
|
|
214
|
-
console.error('Port 4242 is already in use. Close the process using it and retry.');
|
|
215
|
-
process.exit(1);
|
|
216
|
-
}
|
|
217
|
-
reject(err);
|
|
218
|
-
});
|
|
219
|
-
});
|
|
152
|
+
// Always print the URL first: the browser launch is best-effort and
|
|
153
|
+
// silently does nothing on headless/SSH sessions.
|
|
154
|
+
console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
|
|
155
|
+
openUrl(authorizeUrl);
|
|
156
|
+
const tokens = await loop.finish(oauth2Client, expectedState);
|
|
157
|
+
writeToken(alias, tokens);
|
|
158
|
+
console.log(`Token saved (encrypted) for ${alias}.`);
|
|
159
|
+
console.log(TESTING_MODE_WARNING);
|
|
160
|
+
console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
|
|
220
161
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface MtBootGates {
|
|
2
|
+
/** True = multi-tenant deployment: the server boots with zero accounts
|
|
3
|
+
* (tenants provision in-band) and the provisioning gate below replaces the
|
|
4
|
+
* flat owner-allowlist requirement. */
|
|
5
|
+
multiTenant: boolean;
|
|
6
|
+
/** Fail-fast replacement for the E_OWNER_EMAILS_REQUIRED check. MUST refuse
|
|
7
|
+
* (exit/throw) when no valid provisioning mechanism is configured — an
|
|
8
|
+
* ungated HTTP endpoint must never boot, whatever the condition checked. */
|
|
9
|
+
assertProvisioningGate: () => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function setMtBootGates(g: MtBootGates | null): void;
|
|
12
|
+
export declare function mtBootGates(): MtBootGates | null;
|
|
13
|
+
export declare function isMultiTenantBoot(): boolean;
|
|
14
|
+
/** BR-4 applies to stdio always, and to HTTP only when single-owner: a
|
|
15
|
+
* multi-tenant HTTP deploy starts empty by design. */
|
|
16
|
+
export declare function accountsAssertRequired(wantStdio: boolean, multiTenant: boolean): boolean;
|
|
17
|
+
/** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
|
|
18
|
+
* the env registry is process-wide, so it would graft one operator's accounts
|
|
19
|
+
* onto every tenant and turn every tenant's first wizard call into a dead
|
|
20
|
+
* end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
|
|
21
|
+
* of one clear one. */
|
|
22
|
+
export declare function assertNoEnvAccountsMode(env?: NodeJS.ProcessEnv): void;
|
|
23
|
+
/** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
|
|
24
|
+
* global scope profile, so on a multi-tenant box one operator's scope choice
|
|
25
|
+
* would silently apply to every tenant's consent. */
|
|
26
|
+
export declare function assertNoEnvOptionalScopesMode(env?: NodeJS.ProcessEnv): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// S1.16: multi-tenant boot gates. The free core never sets these — every
|
|
2
|
+
// helper reduces to today's single-owner behavior when no gates are
|
|
3
|
+
// installed. Deliberately a side-effect-free module (accounts.ts/auth.ts
|
|
4
|
+
// resolve env at import time) so the tenancy module can import it cheaply.
|
|
5
|
+
import { failStartup } from './config-file.js';
|
|
6
|
+
let gates = null;
|
|
7
|
+
export function setMtBootGates(g) {
|
|
8
|
+
gates = g;
|
|
9
|
+
}
|
|
10
|
+
export function mtBootGates() {
|
|
11
|
+
return gates;
|
|
12
|
+
}
|
|
13
|
+
export function isMultiTenantBoot() {
|
|
14
|
+
return gates?.multiTenant === true;
|
|
15
|
+
}
|
|
16
|
+
/** BR-4 applies to stdio always, and to HTTP only when single-owner: a
|
|
17
|
+
* multi-tenant HTTP deploy starts empty by design. */
|
|
18
|
+
export function accountsAssertRequired(wantStdio, multiTenant) {
|
|
19
|
+
return wantStdio || !multiTenant;
|
|
20
|
+
}
|
|
21
|
+
/** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
|
|
22
|
+
* the env registry is process-wide, so it would graft one operator's accounts
|
|
23
|
+
* onto every tenant and turn every tenant's first wizard call into a dead
|
|
24
|
+
* end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
|
|
25
|
+
* of one clear one. */
|
|
26
|
+
export function assertNoEnvAccountsMode(env = process.env) {
|
|
27
|
+
if (env.GOOGLE_ACCOUNTS?.trim()) {
|
|
28
|
+
failStartup('E_ENV_ACCOUNTS_MODE_MT', 'GOOGLE_ACCOUNTS must not be set on a multi-tenant deployment; accounts are linked in-band per tenant. Unset it and restart.');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
|
|
32
|
+
* global scope profile, so on a multi-tenant box one operator's scope choice
|
|
33
|
+
* would silently apply to every tenant's consent. */
|
|
34
|
+
export function assertNoEnvOptionalScopesMode(env = process.env) {
|
|
35
|
+
if (env.GOOGLE_OPTIONAL_SCOPES?.trim()) {
|
|
36
|
+
failStartup('E_ENV_OPTIONAL_SCOPES_MT', 'GOOGLE_OPTIONAL_SCOPES must not be set on a multi-tenant deployment; scopes are chosen per tenant at consent. Unset it and restart.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare const CIMD_TTL_MS: number;
|
|
2
|
+
/** The oldest validated document still served while the cap is full or the issuer cannot answer. */
|
|
3
|
+
export declare const CIMD_MAX_STALE_MS: number;
|
|
4
|
+
/** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
|
|
5
|
+
export declare const CIMD_MAX_IN_FLIGHT = 4;
|
|
6
|
+
export declare const CIMD_CACHE_MAX = 256;
|
|
7
|
+
export type CimdDocument = Record<string, unknown>;
|
|
8
|
+
type CimdError = {
|
|
9
|
+
error: 'E_CIMD_SSRF_BLOCKED' | 'E_CIMD_INVALID';
|
|
10
|
+
};
|
|
11
|
+
/** `fresh` is true only for the request whose fetch produced the document. */
|
|
12
|
+
export type CimdResolution = {
|
|
13
|
+
doc: CimdDocument;
|
|
14
|
+
fresh: boolean;
|
|
15
|
+
} | {
|
|
16
|
+
busy: true;
|
|
17
|
+
} | CimdError;
|
|
18
|
+
export interface CimdCacheDeps {
|
|
19
|
+
fetch: (clientId: string) => Promise<CimdDocument>;
|
|
20
|
+
now: () => number;
|
|
21
|
+
log: (line: string) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare class CimdClientCache {
|
|
24
|
+
private readonly deps;
|
|
25
|
+
private readonly entries;
|
|
26
|
+
private readonly inFlight;
|
|
27
|
+
private busyLoggedAt;
|
|
28
|
+
private busySuppressed;
|
|
29
|
+
constructor(deps: CimdCacheDeps);
|
|
30
|
+
get size(): number;
|
|
31
|
+
/** `validate` runs once per fetch, in the request that started it, and every
|
|
32
|
+
* request waiting on that fetch gets the same answer. */
|
|
33
|
+
resolve(clientId: string, validate: (doc: CimdDocument) => boolean): Promise<CimdResolution>;
|
|
34
|
+
private fetchFor;
|
|
35
|
+
private store;
|
|
36
|
+
private noteBusy;
|
|
37
|
+
}
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// H4 F6: GET /authorize is unauthenticated, and a client_id on an allowlisted
|
|
2
|
+
// issuer with a path never seen before forces an outbound fetch. So the
|
|
3
|
+
// fetches running at once are capped, one client_id is fetched once however
|
|
4
|
+
// many requests wait on it, and only a document that passed validation is
|
|
5
|
+
// kept. Errors are never cached (CIMD section 5.2), and a negative cache would
|
|
6
|
+
// not help anyway: the attack varies the path, so its key never repeats.
|
|
7
|
+
import { CimdHttpError, isTransientFetchError, SsrfBlockedError } from './ssrf-guard.js';
|
|
8
|
+
import { logSafe } from './trim.js';
|
|
9
|
+
export const CIMD_TTL_MS = 5 * 60_000;
|
|
10
|
+
/** The oldest validated document still served while the cap is full or the issuer cannot answer. */
|
|
11
|
+
export const CIMD_MAX_STALE_MS = 24 * 60 * 60_000;
|
|
12
|
+
/** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
|
|
13
|
+
export const CIMD_MAX_IN_FLIGHT = 4;
|
|
14
|
+
export const CIMD_CACHE_MAX = 256;
|
|
15
|
+
const BUSY_LOG_EVERY_MS = 60_000;
|
|
16
|
+
export class CimdClientCache {
|
|
17
|
+
deps;
|
|
18
|
+
entries = new Map();
|
|
19
|
+
// One entry per running fetch, so its size is the number of permits taken.
|
|
20
|
+
inFlight = new Map();
|
|
21
|
+
busyLoggedAt = -Infinity;
|
|
22
|
+
busySuppressed = 0;
|
|
23
|
+
constructor(deps) {
|
|
24
|
+
this.deps = deps;
|
|
25
|
+
}
|
|
26
|
+
get size() {
|
|
27
|
+
return this.entries.size;
|
|
28
|
+
}
|
|
29
|
+
/** `validate` runs once per fetch, in the request that started it, and every
|
|
30
|
+
* request waiting on that fetch gets the same answer. */
|
|
31
|
+
async resolve(clientId, validate) {
|
|
32
|
+
const entry = this.entries.get(clientId);
|
|
33
|
+
const age = entry ? this.deps.now() - entry.at : 0;
|
|
34
|
+
if (entry && age < CIMD_TTL_MS)
|
|
35
|
+
return { doc: entry.doc, fresh: false };
|
|
36
|
+
if (entry && age >= CIMD_MAX_STALE_MS)
|
|
37
|
+
this.entries.delete(clientId);
|
|
38
|
+
const running = this.inFlight.get(clientId);
|
|
39
|
+
if (running) {
|
|
40
|
+
const out = await running;
|
|
41
|
+
return 'doc' in out ? { doc: out.doc, fresh: false } : out;
|
|
42
|
+
}
|
|
43
|
+
if (this.inFlight.size >= CIMD_MAX_IN_FLIGHT) {
|
|
44
|
+
// A flood of fresh paths is exactly when the cap is full, and a client
|
|
45
|
+
// that signed in before must still be able to.
|
|
46
|
+
const stale = this.entries.get(clientId);
|
|
47
|
+
if (stale)
|
|
48
|
+
return { doc: stale.doc, fresh: false };
|
|
49
|
+
this.noteBusy();
|
|
50
|
+
return { busy: true };
|
|
51
|
+
}
|
|
52
|
+
return this.fetchFor(clientId, validate);
|
|
53
|
+
}
|
|
54
|
+
async fetchFor(clientId, validate) {
|
|
55
|
+
// Never rejects: neither core nor a host installs an unhandledRejection
|
|
56
|
+
// handler, and a waiter that stopped listening must not take the process down.
|
|
57
|
+
let settle;
|
|
58
|
+
this.inFlight.set(clientId, new Promise((r) => (settle = r)));
|
|
59
|
+
let out = { error: 'E_CIMD_INVALID' };
|
|
60
|
+
try {
|
|
61
|
+
const doc = await this.deps.fetch(clientId);
|
|
62
|
+
if (validate(doc)) {
|
|
63
|
+
this.store(clientId, doc);
|
|
64
|
+
out = { doc, fresh: true };
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
this.entries.delete(clientId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
out = { error: e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID' };
|
|
72
|
+
// A document that went bad is never served stale. An issuer that is down
|
|
73
|
+
// or overloaded says nothing about the document, so the last good one
|
|
74
|
+
// stays and answers this fetch's requests too (stale-if-error; the error
|
|
75
|
+
// itself is never stored).
|
|
76
|
+
const stale = this.entries.get(clientId);
|
|
77
|
+
if (!saysNothingAboutDocument(e))
|
|
78
|
+
this.entries.delete(clientId);
|
|
79
|
+
else if (stale && this.deps.now() - stale.at < CIMD_MAX_STALE_MS)
|
|
80
|
+
out = { doc: stale.doc, fresh: false };
|
|
81
|
+
// #13: the detail goes to the log only; the caller gets a generic answer.
|
|
82
|
+
this.deps.log(`CIMD fetch failed for ${logSafe(clientId, 128)}: ${logSafe(e instanceof Error ? e.message : 'non-Error rejection', 200)}`);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
this.inFlight.delete(clientId);
|
|
86
|
+
settle(out);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
store(clientId, doc) {
|
|
91
|
+
this.entries.delete(clientId);
|
|
92
|
+
while (this.entries.size >= CIMD_CACHE_MAX) {
|
|
93
|
+
const oldest = this.entries.keys().next().value;
|
|
94
|
+
if (oldest === undefined)
|
|
95
|
+
break;
|
|
96
|
+
this.entries.delete(oldest);
|
|
97
|
+
}
|
|
98
|
+
this.entries.set(clientId, { doc, at: this.deps.now() });
|
|
99
|
+
}
|
|
100
|
+
// Over-cap requests arrive at whatever rate the caller likes.
|
|
101
|
+
noteBusy() {
|
|
102
|
+
const now = this.deps.now();
|
|
103
|
+
if (now >= this.busyLoggedAt && now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
|
|
104
|
+
this.busySuppressed++;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const suppressed = this.busySuppressed;
|
|
108
|
+
this.busyLoggedAt = now;
|
|
109
|
+
this.busySuppressed = 0;
|
|
110
|
+
this.deps.log(`CIMD busy: in-flight cap reached${suppressed > 0 ? ` (n=${suppressed} suppressed)` : ''}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function saysNothingAboutDocument(e) {
|
|
114
|
+
if (isTransientFetchError(e))
|
|
115
|
+
return true;
|
|
116
|
+
return e instanceof CimdHttpError && (e.status === 408 || e.status === 429 || e.status >= 500);
|
|
117
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
1
|
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
-
import type { Account } from './accounts.js';
|
|
3
|
-
|
|
2
|
+
import type { Account, AccountSet } from './accounts.js';
|
|
3
|
+
import type { TokenData } from './types.js';
|
|
4
|
+
export interface GetClientDeps {
|
|
5
|
+
/** Live account view — read at call time, never a captured snapshot. */
|
|
6
|
+
accounts: () => AccountSet;
|
|
7
|
+
readToken: (alias: string) => TokenData | null;
|
|
8
|
+
updateToken: (alias: string, updates: object) => void;
|
|
9
|
+
/** Cross-process staleness probe (single-owner global registry only). */
|
|
10
|
+
refreshIfStale?: () => void;
|
|
11
|
+
}
|
|
12
|
+
/** Test-only visibility into the dedupe map. */
|
|
13
|
+
export declare function __inflightSizeForTest(): number;
|
|
14
|
+
/**
|
|
15
|
+
* Per-context client factory: `contextKey` namespaces the single-flight map
|
|
16
|
+
* (one lane per tenant), `deps` carry that context's account view and token
|
|
17
|
+
* custody. The free core builds exactly one, bound to the global registry.
|
|
18
|
+
*/
|
|
19
|
+
export declare function makeGetClient(contextKey: string, deps: GetClientDeps): (account: Account) => Promise<OAuth2Client>;
|
|
20
|
+
/** Injection surface for curated tool files: absent = the single-owner
|
|
21
|
+
* getClient below (today's behavior); a tenant context passes its own. */
|
|
22
|
+
export interface CuratedToolDeps {
|
|
23
|
+
getClientFn?: typeof getClient;
|
|
24
|
+
/** Whether tools may read or write the server host's disk (local uploads,
|
|
25
|
+
* downloads, attachments). Absent = true; the host belongs to its operator,
|
|
26
|
+
* so a non-owner context passes false. */
|
|
27
|
+
localFiles?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** The single-owner client, bound to the global registry + token store. The
|
|
30
|
+
* operator surfaces that never carry a tenant (api-probe, executor and
|
|
31
|
+
* escape-hatch `deps.getClientFn ?? getClient` defaults) resolve through
|
|
32
|
+
* this. */
|
|
33
|
+
export declare const getClient: (account: Account) => Promise<OAuth2Client>;
|
package/dist/client.js
CHANGED
|
Binary file
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { ServerTarget } from './http-transport.js';
|
|
3
|
+
import { ToolRegistry, type DiscoveryMode } from './registry.js';
|
|
4
|
+
import { type IdentityContext } from './identity.js';
|
|
5
|
+
import type { Metrics } from './usage-metrics.js';
|
|
6
|
+
export declare function buildRegistry(server: McpServer, ctx: IdentityContext, mode?: DiscoveryMode, metrics?: Metrics | null): ToolRegistry;
|
|
7
|
+
/** The per-request hooks bound to one registry: argument shapes, unknown-
|
|
8
|
+
* argument screening and the account a validation envelope names. Both
|
|
9
|
+
* transports and every resolved server target build them here, so a mistyped
|
|
10
|
+
* argument behaves the same wherever it arrives. */
|
|
11
|
+
export declare function requestHooksFor(registry: ToolRegistry, ctx: Pick<IdentityContext, 'accounts'>, metrics?: Metrics | null, env?: NodeJS.ProcessEnv): Omit<ServerTarget, 'server'>;
|
package/dist/compose.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// S1.12: registry composition, extracted from index.ts so the tenancy module
|
|
2
|
+
// (and tests) can build registries without touching the CLI entrypoint. This
|
|
3
|
+
// module must stay free of import-time side effects of its own: no argv
|
|
4
|
+
// parsing, no process.exit, no prints (accounts.js module-load resolution is
|
|
5
|
+
// the one inherited, pre-existing exception on the import chain).
|
|
6
|
+
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
7
|
+
import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
|
|
8
|
+
import { argNormalizationEnabled } from './arg-normalize.js';
|
|
9
|
+
import { unknownArgMode } from './arg-strict.js';
|
|
10
|
+
import { ToolRegistry } from './registry.js';
|
|
11
|
+
import { registerDiscoverTools } from './discover.js';
|
|
12
|
+
import { registerEscapeTools } from './tools/google-api.js';
|
|
13
|
+
import { accountHealthDepsFor, registerAccountTools } from './tools/accounts-tool.js';
|
|
14
|
+
import { registerDiagnoseTool } from './doctor.js';
|
|
15
|
+
import { registerAccountWizardTools } from './tools/account-wizard.js';
|
|
16
|
+
import { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
17
|
+
import { isOwnerContext } from './identity.js';
|
|
18
|
+
import { resolveScopesForAccount } from './auth.js';
|
|
19
|
+
export function buildRegistry(server, ctx, mode, metrics = null) {
|
|
20
|
+
const policy = ctx.policy;
|
|
21
|
+
// The registry's account view IS the context's: fan-out expansion,
|
|
22
|
+
// selector validation and default-account injection all follow this
|
|
23
|
+
// context, not the process global (identical for the free core's one ctx).
|
|
24
|
+
const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
|
|
25
|
+
// Every handler resolves its client, token reads and scope hints through
|
|
26
|
+
// this context; ctx.accounts is read per call, never snapshotted here, so
|
|
27
|
+
// the owner's live getter stays live.
|
|
28
|
+
const owner = isOwnerContext(ctx);
|
|
29
|
+
const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
|
|
30
|
+
const execDeps = {
|
|
31
|
+
getClientFn: ctx.getClient,
|
|
32
|
+
scopeDeps: {
|
|
33
|
+
readTokenFn: ctx.tokenStore.readToken,
|
|
34
|
+
profileFn: (alias) => resolveScopesForAccount(alias, ctx.accounts),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const toolsets = getToolsets();
|
|
38
|
+
if (toolsets !== 'all') {
|
|
39
|
+
const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
|
|
40
|
+
for (const requested of toolsets) {
|
|
41
|
+
if (!known.has(requested)) {
|
|
42
|
+
process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const svc of SERVICES) {
|
|
47
|
+
if (!toolsetEnabled(toolsets, svc.name))
|
|
48
|
+
continue;
|
|
49
|
+
if (svc.enabled && !svc.enabled(ctx.accounts)) {
|
|
50
|
+
if (toolsets !== 'all') {
|
|
51
|
+
const hint = svc.name === 'admin' ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS)' : `add "${svc.name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`;
|
|
52
|
+
process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
svc.register(registry, clientDeps);
|
|
57
|
+
}
|
|
58
|
+
for (const gen of GENERATED_SERVICES) {
|
|
59
|
+
if (!toolsetEnabled(toolsets, gen.name))
|
|
60
|
+
continue;
|
|
61
|
+
const curated = SERVICES.find((s) => s.name === gen.name);
|
|
62
|
+
const gate = curated?.enabled ?? GENERATED_GATES[gen.name]?.enabled;
|
|
63
|
+
if (gate && !gate(ctx.accounts)) {
|
|
64
|
+
if (!curated && toolsets !== 'all') {
|
|
65
|
+
process.stderr.write(`GOOGLE_TOOLSETS: "${gen.name}" requested but not enabled — ${GENERATED_GATES[gen.name].hint}\n`);
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
gen.register(registry, execDeps);
|
|
70
|
+
}
|
|
71
|
+
if (registry.services().length === 0) {
|
|
72
|
+
const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
|
|
73
|
+
throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
|
|
74
|
+
`Known services: ${known.join(', ')}. ` +
|
|
75
|
+
`Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
|
|
76
|
+
}
|
|
77
|
+
registerDiscoverTools(registry, policy);
|
|
78
|
+
registerEscapeTools(registry, policy, execDeps);
|
|
79
|
+
registerAccountTools(registry, accountHealthDepsFor(ctx.tokenStore));
|
|
80
|
+
registerDiagnoseTool(registry, ctx);
|
|
81
|
+
// The wizard edits the owner's config.json and token store; any other
|
|
82
|
+
// context has neither.
|
|
83
|
+
if (owner)
|
|
84
|
+
registerAccountWizardTools(registry, server, () => servicesAwaitingRestart(registry));
|
|
85
|
+
return registry;
|
|
86
|
+
}
|
|
87
|
+
/** The per-request hooks bound to one registry: argument shapes, unknown-
|
|
88
|
+
* argument screening and the account a validation envelope names. Both
|
|
89
|
+
* transports and every resolved server target build them here, so a mistyped
|
|
90
|
+
* argument behaves the same wherever it arrives. */
|
|
91
|
+
export function requestHooksFor(registry, ctx, metrics = null, env = process.env) {
|
|
92
|
+
const mode = unknownArgMode(env);
|
|
93
|
+
return {
|
|
94
|
+
argShapeFor: argNormalizationEnabled(env) ? (tool) => registry.argShape(tool) : undefined,
|
|
95
|
+
strictArgs: mode === 'off'
|
|
96
|
+
? undefined
|
|
97
|
+
: {
|
|
98
|
+
mode,
|
|
99
|
+
declaredFor: (tool) => registry.declaredKeys(tool),
|
|
100
|
+
siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
|
|
101
|
+
unknownTool: (name) => unknownToolMessage(registry, name),
|
|
102
|
+
onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
|
|
103
|
+
},
|
|
104
|
+
validationEnvelope: {
|
|
105
|
+
isKnownTool: (name) => registry.hasTool(name),
|
|
106
|
+
defaultAccount: () => ctx.accounts.defaultAccount,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
package/dist/config-file.d.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
export declare const CONFIG_VERSION = 1;
|
|
2
2
|
export declare function configDir(env?: NodeJS.ProcessEnv): string;
|
|
3
3
|
export declare function configFilePath(env?: NodeJS.ProcessEnv): string;
|
|
4
|
+
export declare function tenantsDir(env?: NodeJS.ProcessEnv): string;
|
|
5
|
+
export declare function tenantConfigFilePath(tenantId: string, env?: NodeJS.ProcessEnv): string;
|
|
6
|
+
export declare function tenantTokenDir(tenantId: string, env?: NodeJS.ProcessEnv): string;
|
|
7
|
+
/** Create and permission-harden the tenant directory chain. mkdirSync's
|
|
8
|
+
* recursive mode only stamps the DEEPEST newly-created dir, so every level
|
|
9
|
+
* gets an explicit chmod: the shared tenants/ root (created by whichever
|
|
10
|
+
* tenant provisions first) must never stay group-readable. */
|
|
11
|
+
export declare function ensureTenantDirs(tenantId: string, env?: NodeJS.ProcessEnv): {
|
|
12
|
+
configFile: string;
|
|
13
|
+
tokenDir: string;
|
|
14
|
+
};
|
|
4
15
|
export declare const ALIAS_RE: RegExp;
|
|
16
|
+
export declare const RESERVED_ALIASES: string[];
|
|
17
|
+
export declare function isReservedAlias(name: string): boolean;
|
|
5
18
|
export interface ConfigFile {
|
|
6
19
|
version: number;
|
|
7
20
|
accounts?: Record<string, {
|
|
@@ -17,6 +30,7 @@ export interface ConfigFile {
|
|
|
17
30
|
defaultAccount?: string;
|
|
18
31
|
discovery?: 'lazy' | 'curated' | 'eager';
|
|
19
32
|
toolsets?: string;
|
|
33
|
+
usageMetrics?: boolean;
|
|
20
34
|
}
|
|
21
35
|
export declare function failStartup(slug: string, message: string): never;
|
|
22
36
|
export declare class ConfigFileError extends Error {
|
package/dist/config-file.js
CHANGED
|
@@ -10,6 +10,39 @@ export function configDir(env = process.env) {
|
|
|
10
10
|
export function configFilePath(env = process.env) {
|
|
11
11
|
return path.join(configDir(env), 'config.json');
|
|
12
12
|
}
|
|
13
|
+
export function tenantsDir(env = process.env) {
|
|
14
|
+
return path.join(configDir(env), 'tenants');
|
|
15
|
+
}
|
|
16
|
+
// A tenant id becomes a filesystem path segment exactly like an account
|
|
17
|
+
// alias, so it rides the same traversal guard. 'owner' is the single-owner
|
|
18
|
+
// context's subject and client lane, so no tenant may take it.
|
|
19
|
+
function assertTenantId(tenantId) {
|
|
20
|
+
if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId) || tenantId === 'owner') {
|
|
21
|
+
throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function tenantConfigFilePath(tenantId, env = process.env) {
|
|
25
|
+
assertTenantId(tenantId);
|
|
26
|
+
return path.join(tenantsDir(env), tenantId, 'config.json');
|
|
27
|
+
}
|
|
28
|
+
export function tenantTokenDir(tenantId, env = process.env) {
|
|
29
|
+
assertTenantId(tenantId);
|
|
30
|
+
return path.join(tenantsDir(env), tenantId, 'tokens');
|
|
31
|
+
}
|
|
32
|
+
/** Create and permission-harden the tenant directory chain. mkdirSync's
|
|
33
|
+
* recursive mode only stamps the DEEPEST newly-created dir, so every level
|
|
34
|
+
* gets an explicit chmod: the shared tenants/ root (created by whichever
|
|
35
|
+
* tenant provisions first) must never stay group-readable. */
|
|
36
|
+
export function ensureTenantDirs(tenantId, env = process.env) {
|
|
37
|
+
const tokenDir = tenantTokenDir(tenantId, env);
|
|
38
|
+
const levels = [configDir(env), tenantsDir(env), path.join(tenantsDir(env), tenantId), tokenDir];
|
|
39
|
+
for (const dir of levels) {
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
if (process.platform !== 'win32')
|
|
42
|
+
fs.chmodSync(dir, 0o700);
|
|
43
|
+
}
|
|
44
|
+
return { configFile: tenantConfigFilePath(tenantId, env), tokenDir };
|
|
45
|
+
}
|
|
13
46
|
// Alias charset is load-bearing security, not cosmetics: the alias becomes a
|
|
14
47
|
// tokenDir path segment (encPath), so this guard blocks path traversal.
|
|
15
48
|
export const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
|
|
@@ -22,7 +55,10 @@ const accountEntrySchema = z.strictObject({
|
|
|
22
55
|
// masterKey, jwtKey, clientId) fails validation, structurally enforcing
|
|
23
56
|
// secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
|
|
24
57
|
// of the frozen envelope; their consumers land in later slices.
|
|
25
|
-
const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
|
|
58
|
+
export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
|
|
59
|
+
export function isReservedAlias(name) {
|
|
60
|
+
return RESERVED_ALIASES.includes(name);
|
|
61
|
+
}
|
|
26
62
|
const configSchema = z.strictObject({
|
|
27
63
|
version: z.number().int(),
|
|
28
64
|
accounts: z.record(z.string().regex(ALIAS_RE), accountEntrySchema).optional(),
|
|
@@ -36,6 +72,10 @@ const configSchema = z.strictObject({
|
|
|
36
72
|
defaultAccount: z.string().optional(),
|
|
37
73
|
discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
|
|
38
74
|
toolsets: z.string().optional(),
|
|
75
|
+
// Local usage metrics (metrics-feature-spec): absent = off. Downgrade rule:
|
|
76
|
+
// a pre-6.0 build reading a config carrying this key fails E_CONFIG_INVALID
|
|
77
|
+
// (strictObject); remove the key first.
|
|
78
|
+
usageMetrics: z.boolean().optional(),
|
|
39
79
|
});
|
|
40
80
|
export function failStartup(slug, message) {
|
|
41
81
|
process.stderr.write(`${slug}: ${message}\n`);
|