mcp-google-multi 6.0.0-beta.3 → 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.
- package/README.md +5 -5
- package/dist/accounts.d.ts +3 -3
- package/dist/accounts.js +18 -31
- package/dist/arg-normalize.js +15 -35
- package/dist/arg-strict.js +8 -28
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +5 -10
- package/dist/boot-gates.d.ts +1 -1
- package/dist/boot-gates.js +3 -5
- package/dist/cimd-cache.js +5 -11
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +8 -1
- package/dist/compose.js +11 -11
- package/dist/config-file.js +7 -12
- package/dist/discover.js +4 -9
- package/dist/discovery-client.js +14 -33
- package/dist/doctor.d.ts +3 -3
- package/dist/doctor.js +12 -18
- package/dist/env-load.js +2 -5
- package/dist/executor.js +2 -4
- package/dist/fanout.js +8 -17
- package/dist/fs-atomic.d.ts +0 -3
- package/dist/fs-atomic.js +6 -10
- package/dist/hint-provider.js +4 -10
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +5 -5
- package/dist/http-transport.js +39 -92
- package/dist/identity.d.ts +2 -2
- package/dist/identity.js +6 -9
- package/dist/index.js +19 -31
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +11 -22
- package/dist/mcp-token.d.ts +3 -13
- package/dist/mcp-token.js +16 -32
- package/dist/metrics-cli.d.ts +1 -1
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-tap.js +2 -5
- package/dist/migrate-config.d.ts +1 -2
- package/dist/migrate-config.js +3 -5
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +2 -2
- package/dist/oauth-as.js +32 -73
- package/dist/oauth-consent.js +4 -10
- package/dist/outbound-allowlist.js +4 -9
- package/dist/reauth-hint.js +2 -5
- package/dist/registry-transfer.d.ts +1 -1
- package/dist/registry-transfer.js +5 -13
- package/dist/registry.d.ts +1 -9
- package/dist/registry.js +38 -66
- package/dist/scope-catalog.d.ts +5 -0
- package/dist/scope-catalog.js +17 -7
- package/dist/scope-observability.d.ts +2 -3
- package/dist/scope-observability.js +8 -14
- package/dist/services.d.ts +3 -0
- package/dist/services.js +5 -10
- package/dist/ssrf-guard.js +11 -22
- package/dist/tenant-purge.js +2 -4
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.js +22 -39
- package/dist/tools/_local-files.js +4 -8
- package/dist/tools/account-wizard.d.ts +4 -3
- package/dist/tools/account-wizard.js +48 -54
- package/dist/tools/accounts-tool.d.ts +1 -1
- package/dist/tools/accounts-tool.js +1 -1
- package/dist/tools/admin.js +2 -3
- package/dist/tools/analytics.js +4 -5
- package/dist/tools/calendar.js +4 -7
- package/dist/tools/chat.js +2 -3
- package/dist/tools/contacts.js +8 -9
- package/dist/tools/docs.js +4 -9
- package/dist/tools/drive.js +18 -27
- package/dist/tools/forms.js +2 -3
- package/dist/tools/gmail-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +2 -2
- package/dist/tools/gmail.js +26 -40
- package/dist/tools/google-api.js +5 -7
- package/dist/tools/meet.js +2 -3
- package/dist/tools/searchconsole.js +2 -3
- package/dist/tools/sheets.js +2 -3
- package/dist/tools/slides.js +2 -3
- package/dist/tools/tasks.js +2 -3
- package/dist/trim.js +2 -3
- package/dist/usage-metrics.js +5 -12
- package/dist/write-control.d.ts +4 -8
- package/dist/write-control.js +3 -4
- package/package.json +21 -21
package/dist/mcp-token.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// `state` and authorization code, the in-memory single-use replay guard, and
|
|
4
|
-
// the rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
|
|
5
|
-
// (B5), so tokens survive a restart as long as the key persists.
|
|
1
|
+
// Token model: the MCP access token (HS256 JWT, jose-only), signed state and
|
|
2
|
+
// authorization code, the replay guard, and the rotated refresh-token store.
|
|
6
3
|
import { createHash, createHmac, hkdfSync, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
7
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
5
|
import { basename } from 'node:path';
|
|
@@ -126,7 +123,7 @@ export class ReplayGuard {
|
|
|
126
123
|
}
|
|
127
124
|
/** Record a jti as spent. A full guard refuses instead of evicting a live
|
|
128
125
|
* jti: a flood of spends would otherwise make a spent artifact usable again,
|
|
129
|
-
* such as a callback the browser binding refused
|
|
126
|
+
* such as a callback the browser binding refused. `artifactExpMs`
|
|
130
127
|
* keeps the record alive as long as the artifact itself, even when the clock
|
|
131
128
|
* stepped back between its signing and this spend. */
|
|
132
129
|
spend(jti, ttlMs, nowMs, artifactExpMs = 0) {
|
|
@@ -165,10 +162,8 @@ export class ReplayGuard {
|
|
|
165
162
|
if (exp < this.earliest)
|
|
166
163
|
this.earliest = exp;
|
|
167
164
|
}
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// once a second. Walking the map from its head on every call is not cheap: a
|
|
171
|
-
// Map iterator steps over every deleted slot until the table is rebuilt.
|
|
165
|
+
// Swept only when at the cap (a refusal otherwise costs O(1)), and at most
|
|
166
|
+
// once a second: a Map iterator steps over every deleted slot until rebuilt.
|
|
172
167
|
room(nowMs) {
|
|
173
168
|
if (this.seen.size < this.cap)
|
|
174
169
|
return true;
|
|
@@ -190,18 +185,16 @@ export class ReplayGuard {
|
|
|
190
185
|
}
|
|
191
186
|
/** Store files this release writes; an absent `format` is an earlier release's. */
|
|
192
187
|
const REFRESH_FORMAT = 2;
|
|
193
|
-
// r1 token: "r1." + base64url(fid 16 || gen uint32 BE || rand 16 || HMAC-SHA256
|
|
194
|
-
//
|
|
195
|
-
// token can never be read as an earlier release's 43-character token.
|
|
188
|
+
// r1 token: "r1." + base64url(fid 16 || gen uint32 BE || rand 16 || HMAC-SHA256).
|
|
189
|
+
// The dot is outside the base64url alphabet, so it can't be read as a legacy 43-char token.
|
|
196
190
|
const R1_PREFIX = 'r1.';
|
|
197
191
|
const R1_LENGTH = 94;
|
|
198
192
|
const R1_BODY = 36;
|
|
199
193
|
const R1_BYTES = 68;
|
|
200
194
|
const R1_MAC_DOMAIN = Buffer.from('mcp-google-multi refresh r1\n');
|
|
201
195
|
const R1_KEY_INFO = 'mcp-google-multi:refresh-mac:v1';
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// guesses offline at hash speed. scrypt gives that branch a work factor.
|
|
196
|
+
// A passphrase key (deriveKey's bare-sha256 branch) would otherwise let one
|
|
197
|
+
// leaked r1 token test guesses offline at hash speed; scrypt adds a work factor.
|
|
205
198
|
const R1_SCRYPT_SALT = 'mcp-google-multi:refresh-mac:scrypt:v1';
|
|
206
199
|
const R1_SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
207
200
|
const GEN_MAX = 0xffffffff;
|
|
@@ -304,9 +297,8 @@ function checkFormat(file, d) {
|
|
|
304
297
|
throw formatError(file, d.format);
|
|
305
298
|
}
|
|
306
299
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
* `dirty` says something was dropped. */
|
|
300
|
+
// Only well-formed own entries survive, so no malformed record reaches a
|
|
301
|
+
// liveness test or the token codec; `dirty` says something was dropped.
|
|
310
302
|
function decodeData(d) {
|
|
311
303
|
const data = { families: {}, active: {}, spent: {} };
|
|
312
304
|
const x = (d ?? {});
|
|
@@ -378,10 +370,10 @@ function lifetimeMs(name, v) {
|
|
|
378
370
|
* Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
|
|
379
371
|
* use, and a token carries its family and generation under a MAC, so
|
|
380
372
|
* presenting any generation the family has rotated past is REUSE: the family
|
|
381
|
-
* is revoked for the rest of its life
|
|
373
|
+
* is revoked for the rest of its life. The store keeps one
|
|
382
374
|
* record per family, whatever its rotation count. Every read-modify-write goes
|
|
383
375
|
* through a file lock so concurrent stdio+HTTP processes can't lost-update or
|
|
384
|
-
* double-spend
|
|
376
|
+
* double-spend. See docs/internals.md.
|
|
385
377
|
*/
|
|
386
378
|
export class RefreshStore {
|
|
387
379
|
path;
|
|
@@ -504,20 +496,12 @@ export class RefreshStore {
|
|
|
504
496
|
this.log(line);
|
|
505
497
|
}
|
|
506
498
|
catch {
|
|
507
|
-
//
|
|
499
|
+
// log sink failure ignored
|
|
508
500
|
}
|
|
509
501
|
}
|
|
510
502
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
* as deleting it from `data.families` / `data.active` on a revoke, so it
|
|
514
|
-
* can never be presented again (reuse detection for every OTHER family is
|
|
515
|
-
* untouched). Only `sub`'s own entries are ever inspected.
|
|
516
|
-
*
|
|
517
|
-
* `keepKey` is the family just issued: it is excluded from the ranking so
|
|
518
|
-
* a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
|
|
519
|
-
* racing ahead of it under the lock, can never make the brand-new family
|
|
520
|
-
* look like the least recently used one and drop itself. */
|
|
503
|
+
// Evicts sub's own least-recently-used sessions down to `limit`; keepKey (the
|
|
504
|
+
// family just issued) is excluded from ranking. See docs/internals.md.
|
|
521
505
|
enforceFamilyCap(data, sub, limit, lines, keepKey) {
|
|
522
506
|
const entries = [];
|
|
523
507
|
for (const [k, f] of Object.entries(data.families)) {
|
package/dist/metrics-cli.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ interface PromotionRow {
|
|
|
15
15
|
calls: number;
|
|
16
16
|
kind: 'curation-candidate' | 'visibility-failure' | 'generation-gap';
|
|
17
17
|
}
|
|
18
|
-
/** The promotion-queue view: ranked evidence, a human decides
|
|
18
|
+
/** The promotion-queue view: ranked evidence, a human decides. */
|
|
19
19
|
export declare function classifyPromotion(merged: DayAgg, data: PromotionData): PromotionRow[];
|
|
20
20
|
export declare function runMetricsCli(argv: string[], deps: MetricsCliDeps): number;
|
|
21
21
|
export {};
|
package/dist/metrics-cli.js
CHANGED
|
@@ -87,7 +87,7 @@ function renderReport(merged, days, out) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
const tail = (id) => id.split('.').slice(1).join('.');
|
|
90
|
-
/** The promotion-queue view: ranked evidence, a human decides
|
|
90
|
+
/** The promotion-queue view: ranked evidence, a human decides. */
|
|
91
91
|
export function classifyPromotion(merged, data) {
|
|
92
92
|
const curated = new Set();
|
|
93
93
|
for (const id of data.curatedIds) {
|
package/dist/metrics-tap.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { isSchemaValidationText } from './arg-normalize.js';
|
|
2
2
|
const PENDING_CAP = 1_000;
|
|
3
|
-
//
|
|
4
|
-
// the
|
|
5
|
-
// withValidationEnvelope sits BELOW it and converts that same frame into an
|
|
6
|
-
// envelope for the client, so classification here and the contract on the wire
|
|
7
|
-
// stay independent.
|
|
3
|
+
// This tap sees the ORIGINAL prose; withValidationEnvelope sits BELOW it and
|
|
4
|
+
// converts the same frame for the client, so the two stay independent.
|
|
8
5
|
export { isSchemaValidationText };
|
|
9
6
|
/**
|
|
10
7
|
* @param isKnownTool membership test against the REGISTERED tool set. The
|
package/dist/migrate-config.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Synthesize config.json accounts{} from the v5 env (GOOGLE_ACCOUNTS +
|
|
3
3
|
* GOOGLE_ADMIN_ACCOUNTS). Never deletes or edits env; idempotent (a re-run
|
|
4
|
-
* that changes nothing says so).
|
|
5
|
-
* migrated by their own slices' commands.
|
|
4
|
+
* that changes nothing says so).
|
|
6
5
|
*/
|
|
7
6
|
export declare function runMigrateConfig(env?: NodeJS.ProcessEnv): void;
|
package/dist/migrate-config.js
CHANGED
|
@@ -6,8 +6,7 @@ function parseCsv(value) {
|
|
|
6
6
|
/**
|
|
7
7
|
* Synthesize config.json accounts{} from the v5 env (GOOGLE_ACCOUNTS +
|
|
8
8
|
* GOOGLE_ADMIN_ACCOUNTS). Never deletes or edits env; idempotent (a re-run
|
|
9
|
-
* that changes nothing says so).
|
|
10
|
-
* migrated by their own slices' commands.
|
|
9
|
+
* that changes nothing says so).
|
|
11
10
|
*/
|
|
12
11
|
export function runMigrateConfig(env = process.env) {
|
|
13
12
|
const raw = env.GOOGLE_ACCOUNTS;
|
|
@@ -35,7 +34,7 @@ export function runMigrateConfig(env = process.env) {
|
|
|
35
34
|
}
|
|
36
35
|
desired[alias] = { email };
|
|
37
36
|
}
|
|
38
|
-
// Legacy global scopes
|
|
37
|
+
// Legacy global scopes become an explicit named profile every account
|
|
39
38
|
// points at, so removing the env var later changes nothing.
|
|
40
39
|
const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
|
|
41
40
|
let legacyProfile;
|
|
@@ -59,8 +58,7 @@ export function runMigrateConfig(env = process.env) {
|
|
|
59
58
|
const filePath = configFilePath(env);
|
|
60
59
|
const before = loadConfigFile(filePath);
|
|
61
60
|
// Merge per-alias: env owns the alias set and emails, but file-only fields
|
|
62
|
-
// (scopeProfile, admin) survive for retained aliases
|
|
63
|
-
// precedence, where unset GOOGLE_ADMIN_ACCOUNTS preserves file admin flags.
|
|
61
|
+
// (scopeProfile, admin) survive for retained aliases.
|
|
64
62
|
const existing = before?.accounts ?? {};
|
|
65
63
|
const dropped = Object.keys(existing).filter((a) => !(a in desired));
|
|
66
64
|
for (const [alias, entry] of Object.entries(desired)) {
|
package/dist/net-tuning.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
|
-
// Node's happy-eyeballs
|
|
3
|
-
//
|
|
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.
|
|
2
|
+
// Node's happy-eyeballs 250ms-per-attempt default aborts EVERY Google call on
|
|
3
|
+
// a high-latency or broken-IPv6 link while curl works. See docs/internals.md.
|
|
6
4
|
export const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
|
|
7
5
|
const USER_FLAGS = ['--network-family-autoselection-attempt-timeout', '--no-network-family-autoselection'];
|
|
8
6
|
/** Pure decision: the timeout to apply, or null to leave Node's setting alone. */
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export interface GoogleIdentity {
|
|
|
60
60
|
sub?: string;
|
|
61
61
|
hd?: string;
|
|
62
62
|
}
|
|
63
|
-
/** Only trust the id_token identity when Google marks the email verified
|
|
63
|
+
/** Only trust the id_token identity when Google marks the email verified.
|
|
64
64
|
* The token comes straight from Google's token endpoint, so its claims are
|
|
65
65
|
* read unsigned. A malformed sub or hd is dropped, never passed on. */
|
|
66
66
|
export declare function verifiedIdentityFromIdToken(idToken?: string | null): GoogleIdentity | undefined;
|
|
@@ -146,7 +146,7 @@ export interface AuthServer {
|
|
|
146
146
|
* start a re-auth nor learn which aliases exist or what they hold. */
|
|
147
147
|
reauthLink: (alias: string) => string;
|
|
148
148
|
}
|
|
149
|
-
/**
|
|
149
|
+
/** Exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
|
|
150
150
|
* the fixed claude.ai callback. */
|
|
151
151
|
export declare function redirectAllowed(redirectUri: string, docRedirectUris: string[]): boolean;
|
|
152
152
|
export declare function buildAuthServer(config: AuthServerConfig, deps?: AuthServerDeps): AuthServer;
|
package/dist/oauth-as.js
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
// (
|
|
3
|
-
// audience-bound HS256 token for the MCP client (leg A), gates on an owner
|
|
4
|
-
// Google login (leg B, tokens discarded), and separately holds per-alias Google
|
|
5
|
-
// tokens (leg C, unchanged). Mounts into B12's HttpTransportHost as `routes` +
|
|
6
|
-
// an `authenticate` for POST /mcp. All Google/CIMD I/O is injectable so the
|
|
7
|
-
// whole surface is exercised by an offline harness.
|
|
1
|
+
// The minimal OAuth 2.1 Authorization Server + Resource Server: federate-and-hold
|
|
2
|
+
// (own token / discarded owner login / held per-alias tokens). See docs/internals.md.
|
|
8
3
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
4
|
import { isIPv4 } from 'node:net';
|
|
10
5
|
import { decodeJwt } from 'jose';
|
|
@@ -13,21 +8,17 @@ import { fetchCimdDocument } from './ssrf-guard.js';
|
|
|
13
8
|
import { CimdClientCache } from './cimd-cache.js';
|
|
14
9
|
import { logSafe } from './trim.js';
|
|
15
10
|
const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// OOMs the process. Cap client count (FIFO-evict the oldest) and
|
|
19
|
-
// the per-request redirect_uris shape so one entry can't be arbitrarily large.
|
|
11
|
+
// /register is public and unauthenticated, so its store must stay bounded
|
|
12
|
+
// (FIFO-evict the oldest) like every sibling store, or a request loop OOMs.
|
|
20
13
|
export const DCR_MAX_CLIENTS = 1000;
|
|
21
14
|
export const DCR_MAX_REDIRECT_URIS = 10;
|
|
22
15
|
const DCR_MAX_URI_LEN = 2048;
|
|
23
16
|
const OIDC_SUB = /^[\x21-\x7e]{1,255}$/;
|
|
24
17
|
const HOSTED_DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i;
|
|
25
|
-
// RFC 6749
|
|
26
|
-
//
|
|
27
|
-
// query string). A resolveSubject denial outside this set is dropped rather
|
|
28
|
-
// than forwarded unvalidated (H3 PR1).
|
|
18
|
+
// RFC 6749 error_description charset (%x20-21/%x23-5B/%x5D-7E); a denial
|
|
19
|
+
// description outside it is dropped rather than forwarded unvalidated.
|
|
29
20
|
const ERROR_DESCRIPTION_RE = /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,200}$/;
|
|
30
|
-
/** Only trust the id_token identity when Google marks the email verified
|
|
21
|
+
/** Only trust the id_token identity when Google marks the email verified.
|
|
31
22
|
* The token comes straight from Google's token endpoint, so its claims are
|
|
32
23
|
* read unsigned. A malformed sub or hd is dropped, never passed on. */
|
|
33
24
|
export function verifiedIdentityFromIdToken(idToken) {
|
|
@@ -90,7 +81,7 @@ function isLoopbackHostname(h) {
|
|
|
90
81
|
// prefix (which would let "127.evil.com" masquerade as loopback).
|
|
91
82
|
return isIPv4(host) && host.split('.')[0] === '127';
|
|
92
83
|
}
|
|
93
|
-
/**
|
|
84
|
+
/** Exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
|
|
94
85
|
* the fixed claude.ai callback. */
|
|
95
86
|
export function redirectAllowed(redirectUri, docRedirectUris) {
|
|
96
87
|
if (redirectUri === CLAUDE_AI_FIXED_CALLBACK)
|
|
@@ -118,10 +109,7 @@ export function redirectAllowed(redirectUri, docRedirectUris) {
|
|
|
118
109
|
return isLoopbackHostname(du.hostname) && du.protocol === r.protocol && du.pathname === r.pathname;
|
|
119
110
|
});
|
|
120
111
|
}
|
|
121
|
-
//
|
|
122
|
-
// two sign-ins started in one browser do not overwrite each other. __Host-
|
|
123
|
-
// requires Secure, which an http base (the loopback owner) cannot rely on in
|
|
124
|
-
// every browser, so an http base gets a plain host-only name instead.
|
|
112
|
+
// Owner-gate browser binding: one cookie per flow; see docs/internals.md.
|
|
125
113
|
const BIND_HASH = /^[A-Za-z0-9_-]{43}$/;
|
|
126
114
|
function bindCookieName(base, bind) {
|
|
127
115
|
return `${base.startsWith('https:') ? '__Host-' : ''}mgm-og-${bind.slice(0, 16)}`;
|
|
@@ -193,10 +181,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
193
181
|
const base = config.base;
|
|
194
182
|
const resource = config.resourceUri;
|
|
195
183
|
const secret = config.secret;
|
|
196
|
-
// States
|
|
197
|
-
// in-memory replay guard that a restart empties. So they are signed with a
|
|
198
|
-
// key only this AuthServer holds: a restart invalidates every one in flight,
|
|
199
|
-
// instead of letting a spent or unreserved one pass the new, empty guard.
|
|
184
|
+
// States/approvals/codes are signed with a per-process secret; see docs/internals.md.
|
|
200
185
|
const flowSecret = new Uint8Array(randomBytes(32));
|
|
201
186
|
const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
|
|
202
187
|
const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
|
|
@@ -256,7 +241,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
256
241
|
const reg = registered.get(clientId);
|
|
257
242
|
if (reg)
|
|
258
243
|
return { redirect_uris: reg.redirect_uris, dcr: true };
|
|
259
|
-
//
|
|
244
|
+
// Validate the issuer host + scheme BEFORE any outbound fetch, so we
|
|
260
245
|
// only ever fetch an allowlisted issuer (no arbitrary-URL SSRF surface).
|
|
261
246
|
let issuerHost;
|
|
262
247
|
let scheme;
|
|
@@ -273,15 +258,15 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
273
258
|
log(`CIMD rejected: issuer=${logSafe(`${scheme}//${issuerHost}`, 80)} not allowlisted client_id_len=${clientId.length}`);
|
|
274
259
|
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
275
260
|
}
|
|
276
|
-
//
|
|
261
|
+
// Cached only once it validates; the cache logs a failed fetch.
|
|
277
262
|
const got = await cimdClients.resolve(clientId, (doc) => doc.client_id === clientId && Array.isArray(doc.redirect_uris) && doc.redirect_uris.length > 0);
|
|
278
263
|
if ('busy' in got)
|
|
279
264
|
return got;
|
|
280
|
-
//
|
|
265
|
+
// Never echo internal fetch details to the caller (blind-SSRF oracle).
|
|
281
266
|
if ('error' in got)
|
|
282
267
|
return { error: got.error, message: 'invalid client' };
|
|
283
268
|
const uris = got.doc.redirect_uris;
|
|
284
|
-
//
|
|
269
|
+
// Warn on a localhost-only redirect set (loopback impersonation),
|
|
285
270
|
// once per fetch rather than on every sign-in the cache serves.
|
|
286
271
|
if (got.fresh && uris.every((u) => { try {
|
|
287
272
|
return isLoopbackHostname(new URL(u).hostname);
|
|
@@ -299,7 +284,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
299
284
|
/** `incoming`: the artifact spent to get here. If no slot is left for the
|
|
300
285
|
* new state it is unspent, so retrying it works once a slot frees. */
|
|
301
286
|
async function toGoogle(res, sp, incoming) {
|
|
302
|
-
//
|
|
287
|
+
// Without this, a Google URL minted here and forwarded to someone
|
|
303
288
|
// else signs THEM in and hands their subject to the starting client.
|
|
304
289
|
const headers = {};
|
|
305
290
|
if (sp.flow === 'owner_gate') {
|
|
@@ -323,8 +308,6 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
323
308
|
: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
|
|
324
309
|
return redirect(res, authUrl, headers);
|
|
325
310
|
}
|
|
326
|
-
// GET /authorize (validate + interstitial for DCR clients); POST /authorize
|
|
327
|
-
// (a DCR consent approval — verify the signed pending token, then proceed).
|
|
328
311
|
const authorize = async (req, res, url) => {
|
|
329
312
|
if (req.method === 'POST') {
|
|
330
313
|
let form;
|
|
@@ -341,7 +324,6 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
341
324
|
catch {
|
|
342
325
|
return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
|
|
343
326
|
}
|
|
344
|
-
// Only the client leg signs pendings, and it is always the owner sign-in.
|
|
345
327
|
// Defence in depth: a pending from an earlier release, which could carry
|
|
346
328
|
// flow=alias_reauth, was signed with MCP_JWT_KEY and no longer verifies.
|
|
347
329
|
if (pending.flow !== 'owner_gate') {
|
|
@@ -364,10 +346,8 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
364
346
|
const resourceParam = q.get('resource') ?? '';
|
|
365
347
|
const clientState = q.get('state') ?? undefined;
|
|
366
348
|
const aliasParam = q.get('alias') ?? undefined;
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// then re-sign fresh for the Google leg. A tampered tenantId breaks the
|
|
370
|
-
// signature; a caller cannot supply one at all.
|
|
349
|
+
// alias_add: tenant/alias/bundles live inside the SIGNED state, verified
|
|
350
|
+
// and spent single-use before re-signing for Google; a caller can't tamper with them.
|
|
371
351
|
if (q.get('flow') === 'alias_add' && !clientId) {
|
|
372
352
|
let minted;
|
|
373
353
|
try {
|
|
@@ -388,11 +368,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
388
368
|
void _spent;
|
|
389
369
|
return toGoogle(res, sp, minted.jti);
|
|
390
370
|
}
|
|
391
|
-
//
|
|
392
|
-
// client leg — no code is delivered to any client, the refreshed token is
|
|
393
|
-
// written server-side for the alias — so it skips the client_id/redirect/
|
|
394
|
-
// PKCE requirements. The link must be one this server signed (reauthLink),
|
|
395
|
-
// and /callback still binds the completing identity to the alias's email.
|
|
371
|
+
// alias_reauth skips client_id/redirect/PKCE (no client leg); see docs/internals.md.
|
|
396
372
|
if (q.get('flow') === 'alias_reauth' && !clientId) {
|
|
397
373
|
if (config.legacyAliasReauth === false) {
|
|
398
374
|
// Alias-only, tenant-ambiguous: disabled on multi-tenant deployments.
|
|
@@ -413,22 +389,18 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
413
389
|
return clientUnavailable(res);
|
|
414
390
|
if ('error' in client)
|
|
415
391
|
return json(res, 400, { error: 'invalid_client', message: client.message, iss: base }, issHeader);
|
|
416
|
-
//
|
|
392
|
+
// Exact redirect_uri match BEFORE minting anything (open-redirect defense).
|
|
417
393
|
if (!redirectUri || !redirectAllowed(redirectUri, client.redirect_uris)) {
|
|
418
394
|
return errorPage(res, 400, 'E_REDIRECT_URI_MISMATCH', 'redirect_uri is not an allowed callback for this client');
|
|
419
395
|
}
|
|
420
|
-
// (3) PKCE S256 required
|
|
421
396
|
if (!codeChallenge || method !== 'S256') {
|
|
422
397
|
return json(res, 400, { error: 'invalid_request', message: 'code_challenge with S256 required', iss: base }, issHeader);
|
|
423
398
|
}
|
|
424
|
-
// (4) resource must be the canonical resource URI
|
|
425
399
|
if (resourceParam !== resource) {
|
|
426
400
|
return json(res, 400, { error: 'invalid_request', message: `resource must be ${resource}`, iss: base }, issHeader);
|
|
427
401
|
}
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
// link: accepting flow=alias_reauth here let any client (a self-registered
|
|
431
|
-
// one, or a public CIMD client_id) start it and read the alias's scopes.
|
|
402
|
+
// Only after full validation, and always as owner_gate: a caller-supplied
|
|
403
|
+
// flow here would let any client read an alias's scopes via alias_reauth.
|
|
432
404
|
const statePayload = {
|
|
433
405
|
flow: 'owner_gate',
|
|
434
406
|
client_id: clientId,
|
|
@@ -437,12 +409,8 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
437
409
|
client_state: clientState,
|
|
438
410
|
resource,
|
|
439
411
|
};
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
// consent screen names only THIS server, not the requesting app), show an
|
|
443
|
-
// approval interstitial naming the client + destination. CIMD clients skip
|
|
444
|
-
// it — their redirect_uris come from an issuer-allowlisted document a rogue
|
|
445
|
-
// cannot forge, so there is nothing to spoof.
|
|
412
|
+
// DCR clients get a consent interstitial naming the client + destination
|
|
413
|
+
// (confused-deputy defense); CIMD clients skip it. See docs/internals.md.
|
|
446
414
|
if (client.dcr) {
|
|
447
415
|
const pendingToken = await signPending(statePayload, base, flowSecret, nowSec());
|
|
448
416
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Frame-Options': 'DENY' });
|
|
@@ -458,7 +426,6 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
458
426
|
}
|
|
459
427
|
return toGoogle(res, statePayload);
|
|
460
428
|
};
|
|
461
|
-
// GET /callback
|
|
462
429
|
const callback = async (req, res, url) => {
|
|
463
430
|
const code = url.searchParams.get('code') ?? '';
|
|
464
431
|
const stateToken = url.searchParams.get('state') ?? '';
|
|
@@ -529,20 +496,16 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
529
496
|
if (st.flow === 'alias_reauth') {
|
|
530
497
|
if (!st.alias)
|
|
531
498
|
return errorPage(res, 400, 'invalid_request', 'alias_reauth without an alias');
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
// attacker who crafts an alias_reauth link and logs in with their OWN
|
|
535
|
-
// account could inject their tokens (account swap). The alias emails (leg
|
|
536
|
-
// C) are distinct from MCP_OWNER_EMAILS (leg B), so bind to the alias.
|
|
499
|
+
// Identity binding: only overwrite the alias's tokens if the completing
|
|
500
|
+
// Google identity matches the alias's configured account (else account swap).
|
|
537
501
|
const gotEmail = (exchanged.email ?? '').toLowerCase();
|
|
538
502
|
const expected = (deps.aliasEmail?.(st.alias) ?? '').toLowerCase();
|
|
539
503
|
if (!expected || !gotEmail || gotEmail !== expected) {
|
|
540
504
|
log(`alias_reauth identity mismatch for "${logSafe(st.alias, 64)}" (got ${logSafe(gotEmail || 'none', 254)})`);
|
|
541
505
|
return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
|
|
542
506
|
}
|
|
543
|
-
// A completion without a refresh token
|
|
544
|
-
//
|
|
545
|
-
// dies within the hour. Keep the stored token instead.
|
|
507
|
+
// A completion without a refresh token would replace a working credential
|
|
508
|
+
// with one that dies within the hour; keep the stored token instead.
|
|
546
509
|
const refreshToken = exchanged.tokens.refresh_token;
|
|
547
510
|
if (typeof refreshToken !== 'string' || refreshToken === '') {
|
|
548
511
|
log(`alias_reauth for "${logSafe(st.alias, 64)}" returned no refresh token; stored token kept`);
|
|
@@ -551,9 +514,8 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
551
514
|
const granted = typeof exchanged.tokens.scope === 'string' ? exchanged.tokens.scope : undefined;
|
|
552
515
|
const missing = deps.missingScopes?.(st.alias, granted) ?? [];
|
|
553
516
|
const listed = missing.map((m) => `<code>${escapeHtml(m)}</code>`).join(', ');
|
|
554
|
-
// A narrower grant replaces nothing that works
|
|
555
|
-
//
|
|
556
|
-
// grant is not necessarily the account holder's choice.
|
|
517
|
+
// A narrower grant replaces nothing that works: the link is reusable for
|
|
518
|
+
// an hour and the scope isn't signed, so it may not be the holder's choice.
|
|
557
519
|
if (missing.length > 0 && deps.hasToken?.(st.alias)) {
|
|
558
520
|
log(`alias_reauth for "${logSafe(st.alias, 64)}" granted ${missing.length} fewer scope(s); stored token kept`);
|
|
559
521
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
@@ -570,9 +532,8 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
570
532
|
res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>${gap}`);
|
|
571
533
|
return true;
|
|
572
534
|
}
|
|
573
|
-
// owner_gate: resolve the verified email to its subject (default
|
|
574
|
-
//
|
|
575
|
-
// signed into the authz code and flows into every token minted from it.
|
|
535
|
+
// owner_gate: resolve the verified email to its subject (default: owner
|
|
536
|
+
// allowlist -> 'owner'), discard Google tokens; the sub signs into the authz code.
|
|
576
537
|
const email = (exchanged.email ?? '').toLowerCase();
|
|
577
538
|
const iss = encodeURIComponent(base);
|
|
578
539
|
const resolved = email ? resolveSubject(email, { sub: exchanged.sub, hd: exchanged.hd }) : null;
|
|
@@ -590,7 +551,6 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
590
551
|
const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
|
|
591
552
|
return redirect(res, `${st.redirect_uri}${sep}code=${encodeURIComponent(authzCode)}${s}&iss=${iss}`);
|
|
592
553
|
};
|
|
593
|
-
// POST /token
|
|
594
554
|
const token = async (req, res) => {
|
|
595
555
|
let form;
|
|
596
556
|
try {
|
|
@@ -677,8 +637,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
677
637
|
}
|
|
678
638
|
return json(res, 400, { error: 'unsupported_grant_type', message: 'authorization_code or refresh_token only' });
|
|
679
639
|
};
|
|
680
|
-
//
|
|
681
|
-
// redirect_uris array must survive intact).
|
|
640
|
+
// Parse the raw JSON body directly; the redirect_uris array must survive intact.
|
|
682
641
|
const register = async (req, res) => {
|
|
683
642
|
let parsed;
|
|
684
643
|
try {
|
package/dist/oauth-consent.js
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { OAuth2Client } from 'googleapis-common';
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
|
-
// Shared loopback OAuth consent
|
|
5
|
-
//
|
|
6
|
-
// process.exit, and a timeout so a never-completed consent can't wedge a tool
|
|
7
|
-
// call forever. The listener binds an EPHEMERAL loopback port (RFC 8252 §7.3;
|
|
8
|
-
// Google Desktop clients accept any http://localhost:<port> redirect), so a
|
|
9
|
-
// second local process on a fixed port can never break auth.
|
|
4
|
+
// Shared loopback OAuth consent, used by the account wizard and `auth
|
|
5
|
+
// --account`; a timeout keeps a never-completed consent from wedging a tool call forever.
|
|
10
6
|
/** Google authorize-URL options for the owner's HTTP authorization server.
|
|
11
7
|
* alias_reauth asks for the alias's scopes plus openid/email, which /callback
|
|
12
8
|
* needs to bind the returned identity to the alias. It carries no login_hint:
|
|
@@ -44,10 +40,8 @@ export class ConsentDeniedError extends Error {
|
|
|
44
40
|
super(`E_CONSENT_DENIED: ${reason}`);
|
|
45
41
|
}
|
|
46
42
|
}
|
|
47
|
-
// Surfaced after every successful consent: the
|
|
48
|
-
//
|
|
49
|
-
// one place the warning is guaranteed to be seen. Full walkthrough in
|
|
50
|
-
// docs/google-cloud-setup.md.
|
|
43
|
+
// Surfaced after every successful consent: the moment of success is the one
|
|
44
|
+
// place this warning is guaranteed to be seen, before the token dies a week later as reauth_required.
|
|
51
45
|
export const TESTING_MODE_WARNING = 'Heads-up: while your OAuth client\'s Publishing status is "Testing", Google expires refresh tokens after 7 days (weekly re-auth for every account). ' +
|
|
52
46
|
'When your setup works, set it to "In production" at https://console.cloud.google.com/auth/audience. ' +
|
|
53
47
|
'No verification review is needed for personal use; see docs/google-cloud-setup.md.';
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
// Opt-in outbound recipient allowlist
|
|
2
|
-
//
|
|
3
|
-
// or share outside the operator's list), a safety feature first and an
|
|
4
|
-
// enterprise checkbox second. FREE-core forever (ee-definition-v0 handoff).
|
|
5
|
-
// Off by default: an unset/empty GOOGLE_OUTBOUND_ALLOWLIST gates nothing.
|
|
1
|
+
// Opt-in outbound recipient allowlist: a prompt-injection blast-radius
|
|
2
|
+
// control (a hijacked agent cannot mail/invite/share outside the operator's list). Off by default.
|
|
6
3
|
import { hints } from './hint-provider.js';
|
|
7
4
|
/** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
|
|
8
5
|
* suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
|
|
@@ -59,10 +56,8 @@ export function checkOutbound(kind, emails, account, env = process.env) {
|
|
|
59
56
|
const blocked = outboundViolations(emails, env);
|
|
60
57
|
return blocked.length > 0 ? outboundDeniedEnvelope(kind, blocked, account, env) : null;
|
|
61
58
|
}
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// (base64 RFC 822), so with the allowlist active they are refused outright in
|
|
65
|
-
// favor of the curated tools that enforce it.
|
|
59
|
+
// RAW compose methods can't be inspected (base64 RFC 822), so with the
|
|
60
|
+
// allowlist active they're refused outright in favor of the curated tools that enforce it.
|
|
66
61
|
const RAW_SEND_METHODS = new Set(['gmail.users.messages.send', 'gmail.users.drafts.send', 'gmail.users.drafts.create', 'gmail.users.messages.insert', 'gmail.users.messages.import']);
|
|
67
62
|
export function collectBodyRecipients(body) {
|
|
68
63
|
const out = [];
|
package/dist/reauth-hint.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// user cannot run a CLI, so the hint becomes a clickable, server-signed link
|
|
4
|
-
// into the AS's clientless alias_reauth flow (AuthServer.reauthLink). The HTTP
|
|
5
|
-
// bootstrap installs the linker when the AS starts; stdio never does.
|
|
1
|
+
// Over stdio the fix is the `auth --account` CLI; over HTTP it's a clickable,
|
|
2
|
+
// server-signed link instead, installed by the AS bootstrap. See docs/internals.md.
|
|
6
3
|
import { hints } from './hint-provider.js';
|
|
7
4
|
let httpReauthLink = null;
|
|
8
5
|
export function setHttpReauthLink(linker) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Refuse-only for v1: export/import operate on the FLAT single-owner
|
|
2
2
|
* store. A tenant-namespaced layout means the bundle would either miss every
|
|
3
3
|
* tenant's tokens or exfiltrate all of them in one file — refuse instead.
|
|
4
4
|
* Returns the offending dir, or null when the store is flat. */
|
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// (portable across machines with different MASTER_KEYs) via the token-store AES
|
|
4
|
-
// primitives; the bundled <alias>.enc token files stay encrypted under the
|
|
5
|
-
// SOURCE MASTER_KEY (double-locked), so the target needs the same MASTER_KEY to
|
|
6
|
-
// use them. Secrets (MASTER_KEY / client secret) are never in the bundle.
|
|
1
|
+
// Encrypted registry export/import: the bundle is passphrase-encrypted, but
|
|
2
|
+
// the bundled <alias>.enc files stay double-locked under the SOURCE MASTER_KEY.
|
|
7
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
8
4
|
import * as readline from 'node:readline';
|
|
9
5
|
import path from 'node:path';
|
|
@@ -11,7 +7,7 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:
|
|
|
11
7
|
import { atomicWriteFileSync, atomicWriteWithLock } from './fs-atomic.js';
|
|
12
8
|
import { ALIAS_RE, configFilePath, CONFIG_VERSION, tenantsDir } from './config-file.js';
|
|
13
9
|
import { getTokenDir } from './accounts.js';
|
|
14
|
-
/**
|
|
10
|
+
/** Refuse-only for v1: export/import operate on the FLAT single-owner
|
|
15
11
|
* store. A tenant-namespaced layout means the bundle would either miss every
|
|
16
12
|
* tenant's tokens or exfiltrate all of them in one file — refuse instead.
|
|
17
13
|
* Returns the offending dir, or null when the store is flat. */
|
|
@@ -61,12 +57,8 @@ export function buildTransferBundle(exportedAt, deps = {}) {
|
|
|
61
57
|
}
|
|
62
58
|
return { manifest: { v: 1, exportedAt, aliases: [...aliasSet].sort() }, config, tokens };
|
|
63
59
|
}
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// an exported bundle could be brute-forced offline (CodeQL
|
|
67
|
-
// js/insufficient-password-hash). scrypt with a per-bundle random salt fixes
|
|
68
|
-
// that; v1 bundles (a prerelease-only format, never produced by any stable
|
|
69
|
-
// release) are refused with a re-export hint rather than kept decryptable.
|
|
60
|
+
// v2: a REAL passphrase KDF (scrypt, per-bundle salt). v1 reused the token
|
|
61
|
+
// store's unsalted-sha256 passphrase branch (CodeQL js/insufficient-password-hash) and is refused outright.
|
|
70
62
|
const BUNDLE_VERSION = 2;
|
|
71
63
|
const SCRYPT_PARAMS = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
72
64
|
function bundleKey(passphrase, salt) {
|
package/dist/registry.d.ts
CHANGED
|
@@ -51,8 +51,6 @@ export declare class ToolRegistry {
|
|
|
51
51
|
private readonly revealed;
|
|
52
52
|
private readonly jsonSchemaCache;
|
|
53
53
|
private readonly argShapeCache;
|
|
54
|
-
/** Memoized isUngrantable verdict per tool: the answer depends on the frozen
|
|
55
|
-
* bundle catalog only, so it never changes within a process. */
|
|
56
54
|
private readonly ungrantable;
|
|
57
55
|
private readonly compactOutput;
|
|
58
56
|
private registeringMeta;
|
|
@@ -106,7 +104,7 @@ export declare class ToolRegistry {
|
|
|
106
104
|
/** discover_all: advertise the full curated set at once. Idempotent. */
|
|
107
105
|
expand(): boolean;
|
|
108
106
|
/** discover_reset: back to the lean meta-only surface. Clears reveals too.
|
|
109
|
-
*
|
|
107
|
+
* If a client mishandles a SHRINKING tools/list, this degrades to a
|
|
110
108
|
* no-op for that session — tools stay callable regardless (graceful
|
|
111
109
|
* dispatch), zero correctness impact. */
|
|
112
110
|
collapse(): boolean;
|
|
@@ -128,11 +126,5 @@ export declare class ToolRegistry {
|
|
|
128
126
|
};
|
|
129
127
|
installListHandler(): void;
|
|
130
128
|
private toToolJson;
|
|
131
|
-
/** Advertise the CURRENT alias enum on every tools/list: account validation
|
|
132
|
-
* is (or is becoming) live, so a cached boot-time enum would go stale the
|
|
133
|
-
* moment account_add lands mid-session. The structural schema stays cached;
|
|
134
|
-
* only the account property's values are refreshed per call. Skipped for
|
|
135
|
-
* the subject-taking account tools (their `account` is the OPERAND, e.g. a
|
|
136
|
-
* brand-new alias name, not an identity to enumerate). */
|
|
137
129
|
private withLiveAccountEnum;
|
|
138
130
|
}
|