@ateam-ai/mcp 0.4.91 → 0.4.93

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai/mcp",
3
- "version": "0.4.91",
3
+ "version": "0.4.93",
4
4
  "mcpName": "io.github.ariekogan/ateam-mcp",
5
5
  "description": "A-Team MCP Server — build, validate, and deploy multi-agent solutions from any AI environment",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "start:http": "node src/index.js --http",
14
14
  "dev": "node --watch src/index.js",
15
15
  "dev:http": "node --watch src/index.js --http",
16
- "test": "node test/session-isolation.test.mjs && node test/widget-protocol.test.mjs && node test/actor-binding.test.mjs && node test/spec-topics.test.mjs && node test/example-types.test.mjs && node --test test/deploy-status-truth.test.mjs && node --test test/connector-source-provenance.test.mjs && node --test test/bootstrap-base-url.test.mjs"
16
+ "test": "node test/session-isolation.test.mjs && node test/widget-protocol.test.mjs && node test/actor-binding.test.mjs && node test/spec-topics.test.mjs && node test/example-types.test.mjs && node --test test/deploy-status-truth.test.mjs && node --test test/connector-source-provenance.test.mjs && node --test test/bootstrap-base-url.test.mjs && node --test test/key-environment.test.mjs"
17
17
  },
18
18
  "keywords": [
19
19
  "mcp",
package/src/api.js CHANGED
@@ -39,18 +39,135 @@ const authOverrides = new Map(); // bearerToken → { tenant, apiKey, updatedAt
39
39
  const sessionBearers = new Map(); // sessionId → bearerToken
40
40
 
41
41
  /**
42
- * Parse a tenant-embedded API key.
43
- * Format: adas_<tenant>_<32hex>
44
- * Legacy: adas_<32hex> (no tenant embedded)
45
- * @returns {{ tenant: string|null, isValid: boolean }}
42
+ * THE ENVIRONMENTS A KEY MAY NAME. A CLOSED SET, deliberately.
43
+ *
44
+ * If this were an open pattern like [a-z]+, a typo — `adas_prd_…` — would
45
+ * become a NEW VALID ENVIRONMENT rather than an error, and the caller would be
46
+ * routed somewhere that does not exist instead of being told they mistyped.
47
+ * That is the same silent-wrong class as an environment fallback. Add an
48
+ * environment HERE, in one place, or it does not exist.
49
+ */
50
+ export const KEY_ENVIRONMENTS = Object.freeze({
51
+ prod: "https://api.ateam-ai.com",
52
+ dev: "https://dev-api.ateam-ai.com",
53
+ });
54
+
55
+ const TENANT_RE = "[a-z0-9][a-z0-9-]{0,28}[a-z0-9]";
56
+ const ENV_KEY_RE = new RegExp(`^adas_(${Object.keys(KEY_ENVIRONMENTS).join("|")})_(${TENANT_RE})_([0-9a-f]{32})$`);
57
+ const PLAIN_KEY_RE = new RegExp(`^adas_(${TENANT_RE})_([0-9a-f]{32})$`);
58
+
59
+ /**
60
+ * THE SEALED FORM — `adas_<env>_<blob>`, where the tenant is INSIDE the blob.
61
+ *
62
+ * base64url of [version:1][nonce:8][AES-256-GCM(tenant)][tag:8][secret:16].
63
+ * The trailing 16 bytes — the actual secret — are in the clear; the sealing key
64
+ * protects ROUTING ONLY. So the blob is not "the encrypted key", and losing the
65
+ * sealing secret is an availability problem, not a credential breach.
66
+ *
67
+ * THIS FILE MUST NEVER DECODE IT, and the reason is specific to this package:
68
+ * `@ateam-ai/mcp` installs from npm onto developer laptops. Any decoder here
69
+ * would mean the sealing secret shipping with it. We learn the tenant by asking
70
+ * — GET /auth/whoami — never by parsing.
71
+ *
72
+ * Length bounds come from that byte layout: 1-char tenant = 34 bytes = 46
73
+ * base64url chars; 30-char tenant = 63 bytes = 84.
74
+ */
75
+ const SEALED_KEY_RE = new RegExp(`^adas_(${Object.keys(KEY_ENVIRONMENTS).join("|")})_([A-Za-z0-9_-]{46,88})$`);
76
+
77
+ /**
78
+ * Parse an API key.
79
+ * Sealed: adas_<env>_<blob> (tenant NOT in the string — ask whoami)
80
+ * Format: adas_<env>_<tenant>_<32hex> env ∈ prod|dev
81
+ * Older: adas_<tenant>_<32hex> (no environment named)
82
+ * Legacy: adas_<32hex> (no tenant either)
83
+ *
84
+ * ORDER IS LOAD-BEARING, and it is the order Core resolves in. base64url
85
+ * includes `-` and `_`, so a long-tenant key with a malformed secret has the
86
+ * SHAPE of a sealed blob. Trying the strict forms first means every well-formed
87
+ * key is claimed by the form it belongs to, and only genuine leftovers reach the
88
+ * blob pattern — where they parse as "sealed", fail to decrypt at Core, and
89
+ * authenticate as NOBODY. That residue is acceptable only because nothing here
90
+ * makes an authorisation decision. Reverse the order and a typo masquerades as
91
+ * a sealed key.
92
+ *
93
+ * `tenant: null` with `sealed: true` is the CORRECT answer, not a failure.
94
+ *
95
+ * `env: null` means the key does not SAY which environment it belongs to — not
96
+ * that it is production. Nothing here defaults it; a caller that needs to know
97
+ * must treat null as unknown.
98
+ *
99
+ * @returns {{ env: string|null, tenant: string|null, sealed: boolean, isValid: boolean }}
46
100
  */
47
101
  export function parseApiKey(key) {
48
- if (!key || typeof key !== 'string') return { tenant: null, isValid: false };
49
- const match = key.match(/^adas_([a-z0-9][a-z0-9-]{0,28}[a-z0-9])_([0-9a-f]{32})$/);
50
- if (match) return { tenant: match[1], isValid: true };
102
+ const no = { env: null, tenant: null, sealed: false, isValid: false };
103
+ if (!key || typeof key !== 'string') return no;
104
+ const withEnv = key.match(ENV_KEY_RE);
105
+ if (withEnv) return { env: withEnv[1], tenant: withEnv[2], sealed: false, isValid: true };
106
+ const match = key.match(PLAIN_KEY_RE);
107
+ if (match) return { env: null, tenant: match[1], sealed: false, isValid: true };
51
108
  const legacy = key.match(/^adas_([0-9a-f]{32})$/);
52
- if (legacy) return { tenant: null, isValid: true };
53
- return { tenant: null, isValid: false };
109
+ if (legacy) return { env: null, tenant: null, sealed: false, isValid: true };
110
+ const sealed = key.match(SEALED_KEY_RE);
111
+ if (sealed) return { env: sealed[1], tenant: null, sealed: true, isValid: true };
112
+ return no;
113
+ }
114
+
115
+ /**
116
+ * ASK WHO THIS KEY IS. The replacement for splitting the string.
117
+ *
118
+ * Deliberately a bare fetch rather than request(): it runs BEFORE the session
119
+ * has credentials, which is the whole point — request() builds its headers from
120
+ * the session we are trying to populate.
121
+ *
122
+ * Returns { tenant, env } or throws. It does NOT fall back to anything. A key
123
+ * whose tenant cannot be established is a key we refuse to act for: guessing
124
+ * here would put a caller on someone else's data, which is the single failure
125
+ * this system must never have.
126
+ *
127
+ * `/auth/whoami` is served by the skill-validator (api.ateam-ai.com and
128
+ * dev-api.ateam-ai.com are BOTH the validator, not Core), which relays the
129
+ * tenant Core gave it when it verified the key. Same answer, one hop.
130
+ */
131
+ export async function whoami(apiKey, baseUrl, { timeoutMs = 10_000 } = {}) {
132
+ if (!apiKey) throw new Error("whoami: no api key");
133
+ if (!baseUrl) throw new Error("whoami: no base url");
134
+ const res = await fetch(`${String(baseUrl).replace(/\/+$/, "")}/auth/whoami`, {
135
+ headers: { "X-API-KEY": apiKey },
136
+ signal: AbortSignal.timeout(timeoutMs),
137
+ });
138
+ const text = await res.text().catch(() => "");
139
+ if (!res.ok) {
140
+ throw new Error(`whoami failed at ${baseUrl} (HTTP ${res.status}): ${text.slice(0, 300)}`);
141
+ }
142
+ let json;
143
+ try { json = JSON.parse(text); } catch { throw new Error(`whoami returned non-JSON from ${baseUrl}: ${text.slice(0, 200)}`); }
144
+ if (!json?.ok || !json?.tenant) {
145
+ throw new Error(`whoami did not name a tenant at ${baseUrl}: ${text.slice(0, 300)}`);
146
+ }
147
+ return { tenant: json.tenant, env: json.env ?? null };
148
+ }
149
+
150
+ /** The API base a key's environment names, or null when it names none. */
151
+ export function baseUrlForKeyEnv(key) {
152
+ const { env } = parseApiKey(key);
153
+ return env ? KEY_ENVIRONMENTS[env] : null;
154
+ }
155
+
156
+ /**
157
+ * Which known environment does this URL belong to? null = not a known host.
158
+ *
159
+ * Used to REFUSE a `url` that contradicts the key. Deliberately only recognises
160
+ * the known hosts: an unrecognised url (localhost, a staging box) is still
161
+ * allowed through, because the override exists for those — it just must not be
162
+ * a way to cross prod/dev by accident.
163
+ */
164
+ export function envForBaseUrl(url) {
165
+ if (!url) return null;
166
+ const norm = String(url).replace(/\/+$/, "");
167
+ for (const [env, base] of Object.entries(KEY_ENVIRONMENTS)) {
168
+ if (norm === base) return env;
169
+ }
170
+ return null;
54
171
  }
55
172
 
56
173
  /**
@@ -65,20 +182,42 @@ export function setSessionCredentials(sessionId, { tenant, apiKey, apiUrl, expli
65
182
  const parsed = parseApiKey(apiKey);
66
183
  if (parsed.tenant) resolvedTenant = parsed.tenant;
67
184
  }
185
+ // A SEALED key legitimately carries no tenant, so "unresolved" here means two
186
+ // very different things and they must not share an outcome:
187
+ //
188
+ // sealed → the tenant is not IN the string and never will be. Null is the
189
+ // honest answer until whoami is asked. Requests still work: Core
190
+ // resolves the tenant from the key itself, and headers() simply
191
+ // omits X-ADAS-TENANT rather than sending a guess.
192
+ // not sealed → the key is malformed. Still a hard failure.
193
+ //
194
+ // The distinction is the whole discipline: we never INVENT a tenant, but
195
+ // "not stated yet" is not the same as "wrong", and conflating them would
196
+ // refuse every sealed key at the door.
197
+ const sealedKey = apiKey ? parseApiKey(apiKey).sealed : false;
68
198
  // Fail loudly — silent fallback to "main" previously let malformed API keys
69
199
  // or missing tenant args silently pivot all operations onto the wrong tenant.
70
200
  // Matches the pattern we killed in ADAS connectors (memory-mcp, docs-index-mcp,
71
201
  // nutrition-mcp) — `|| "default"` was the #1 source of cross-tenant leaks.
72
- if (!resolvedTenant) {
202
+ if (!resolvedTenant && !sealedKey) {
73
203
  throw new Error(
74
204
  `setSessionCredentials: tenant could not be resolved for session ${sessionId} ` +
75
- `(tenant arg ${tenant ? "present" : "missing"}, apiKey ${apiKey ? "present but malformed (expected adas_<tenant>_<hex>)" : "absent"}). ` +
205
+ `(tenant arg ${tenant ? "present" : "missing"}, apiKey ${apiKey ? "present but malformed (expected adas_<env>_<key>)" : "absent"}). ` +
76
206
  `Refusing to fall back to a default tenant.`
77
207
  );
78
208
  }
209
+ if (!resolvedTenant) {
210
+ console.warn(
211
+ `[Auth] Session ${sessionId} holds a sealed key whose tenant is not resolved yet. ` +
212
+ `Calls will still authenticate (the tenant is inside the key and Core resolves it); ` +
213
+ `anything that needs the tenant BY NAME must call whoami rather than assume one.`
214
+ );
215
+ }
79
216
  const existing = sessions.get(sessionId);
80
217
  sessions.set(sessionId, {
81
- tenant: resolvedTenant,
218
+ // Never `undefined` — a missing tenant is an explicit null, so every reader
219
+ // sees "not resolved" rather than an absent property it might paper over.
220
+ tenant: resolvedTenant || null,
82
221
  apiKey,
83
222
  apiUrl: apiUrl || existing?.apiUrl || null,
84
223
  authExplicit: explicit || existing?.authExplicit || false,
@@ -88,7 +227,7 @@ export function setSessionCredentials(sessionId, { tenant, apiKey, apiUrl, expli
88
227
  });
89
228
  const urlNote = apiUrl ? `, url: ${apiUrl}` : "";
90
229
  const masterNote = masterKey ? ", MASTER MODE" : "";
91
- console.log(`[Auth] Credentials set for session ${sessionId} (tenant: ${resolvedTenant}${explicit ? ", explicit" : ""}${urlNote}${masterNote})`);
230
+ console.log(`[Auth] Credentials set for session ${sessionId} (tenant: ${resolvedTenant || "unresolved — sealed key"}${explicit ? ", explicit" : ""}${urlNote}${masterNote})`);
92
231
  }
93
232
 
94
233
  /**
@@ -135,10 +274,15 @@ export function getCredentials(sessionId) {
135
274
  // If apiKey is present but tenant couldn't be derived, the key is malformed.
136
275
  // Previously fell back to "main" — this silently routed credentials to the
137
276
  // wrong tenant. Now we fail loudly.
138
- if (apiKey && !tenant) {
277
+ //
278
+ // UNLESS THE KEY IS SEALED, where no tenant in the string is the design and
279
+ // not a defect. Same split as setSessionCredentials: "not stated" is not
280
+ // "wrong". Requests still authenticate, because the tenant is inside the key
281
+ // and Core reads it; headers() omits X-ADAS-TENANT rather than guessing one.
282
+ if (apiKey && !tenant && !parseApiKey(apiKey).sealed) {
139
283
  throw new Error(
140
284
  `getCredentials: apiKey is present (env ADAS_API_KEY) but tenant could not be resolved ` +
141
- `(missing ADAS_TENANT env and apiKey is malformed — expected format adas_<tenant>_<hex>). ` +
285
+ `(missing ADAS_TENANT env and apiKey is malformed — expected format adas_<env>_<key>). ` +
142
286
  `Refusing to fall back to a default tenant.`
143
287
  );
144
288
  }
package/src/http.js CHANGED
@@ -24,7 +24,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
24
24
  import express from "express";
25
25
  import { createServer } from "./server.js";
26
26
  import {
27
- clearSession, setSessionCredentials, parseApiKey,
27
+ clearSession, setSessionCredentials, parseApiKey, whoami, baseUrlForKeyEnv, getCredentials,
28
28
  startSessionSweeper, getSessionStats, sweepStaleSessions,
29
29
  bindSessionBearer, getAuthOverride, getSessionBearer, bearerOwnershipOk,
30
30
  } from "./api.js";
@@ -180,24 +180,34 @@ export function startHttpServer(port = 3100) {
180
180
  next();
181
181
  };
182
182
 
183
- // Bearer auth middleware chains for MCP routes:
184
- // - "/" (Claude.ai): strict OAuth — Bearer token required
185
- // - "/mcp" (ChatGPT): optional OAuth — validate Bearer if present, pass through if not
186
- const mcpAuthStrict = bearerMiddleware
183
+ // Bearer auth middleware for MCP routes — STRICT ON BOTH PATHS.
184
+ //
185
+ // "/mcp" used to be optional-auth (704206e): validate a Bearer if one is
186
+ // present, otherwise let the request through so the caller could authenticate
187
+ // in-band with the ateam_auth tool. That reads as permissive, and it is —
188
+ // but permissiveness is not free, because SILENCE IS AN ANSWER TO A CLIENT.
189
+ //
190
+ // An OAuth client discovers that it must send a token by being REFUSED one
191
+ // that lacks it: 401 plus WWW-Authenticate pointing at the resource metadata
192
+ // (RFC 9728, and what the MCP authorization spec builds on). Answering 200 to
193
+ // an anonymous request tells the client the endpoint is public, so it never
194
+ // attaches the token it is holding.
195
+ //
196
+ // That is exactly what happened to ChatGPT, measured rather than guessed:
197
+ // 107 requests to /mcp, ZERO carrying an Authorization header, while the
198
+ // connector had completed OAuth and held a valid token. Every session was
199
+ // anonymous, so every tenant tool refused, and ChatGPT disabled the connector
200
+ // — a failure that looks like a broken server and is actually a server that
201
+ // never asked. The token was there the whole time.
202
+ //
203
+ // The in-band ateam_auth path is NOT lost: a client authenticates with its
204
+ // bearer and may still call ateam_auth to switch tenants or point at another
205
+ // environment. What is gone is authenticating with nothing at all, which
206
+ // never worked for an OAuth client anyway — it only looked like it did.
207
+ const mcpAuth = bearerMiddleware
187
208
  ? [autoInjectToken, bearerMiddleware]
188
209
  : [];
189
210
 
190
- // Optional auth: if Bearer token present, validate it (sets req.auth for seedCredentials).
191
- // If no token, let the request through — user can authenticate via ateam_auth tool.
192
- const optionalBearerAuth = bearerMiddleware
193
- ? (req, res, next) => {
194
- if (!req.headers.authorization) return next();
195
- bearerMiddleware(req, res, next);
196
- }
197
- : (_req, _res, next) => next();
198
-
199
- const mcpAuthOptional = [autoInjectToken, optionalBearerAuth];
200
-
201
211
  // ─── CORS — required for browser-based MCP clients ──────────────
202
212
  // Origin allowlist (round 014 security hardening).
203
213
  // ATEAM_CORS_ALLOWED_ORIGINS env = comma-separated list, or "*" / unset for
@@ -302,7 +312,7 @@ export function startHttpServer(port = 3100) {
302
312
  if (sessionId && transports[sessionId]) {
303
313
  // Reuse existing session — seed credentials if Bearer token present
304
314
  transport = transports[sessionId];
305
- seedCredentials(req, sessionId);
315
+ await seedCredentials(req, sessionId);
306
316
  } else if (isInitializeRequest(req.body) || (sessionId && !transports[sessionId])) {
307
317
  // New session, OR stale session with any request type (server restart recovery).
308
318
  // Many MCP clients (Claude mobile, Claude Code) cache the session ID and fail to
@@ -326,7 +336,7 @@ export function startHttpServer(port = 3100) {
326
336
  const newSessionId = sessionId || randomUUID();
327
337
 
328
338
  // Seed credentials from OAuth Bearer token before server starts
329
- seedCredentials(req, newSessionId);
339
+ await seedCredentials(req, newSessionId);
330
340
 
331
341
  transport = new StreamableHTTPServerTransport({
332
342
  sessionIdGenerator: () => newSessionId,
@@ -418,14 +428,12 @@ export function startHttpServer(port = 3100) {
418
428
  await transports[sessionId].handleRequest(req, res);
419
429
  };
420
430
 
421
- // Mount MCP handlers at both "/" and "/mcp"
422
- // "/" (Claude.ai): strict OAuthrequires Bearer token
423
- // "/mcp" (ChatGPT): optional auth — accepts OAuth OR ateam_auth tool
431
+ // Mount MCP handlers at both "/" (Claude.ai) and "/mcp" (ChatGPT).
432
+ // ONE auth rule for both see mcpAuth above for why the split was removed.
424
433
  for (const path of MCP_PATHS) {
425
- const auth = path === "/" ? mcpAuthStrict : mcpAuthOptional;
426
- app.post(path, ...auth, mcpPost);
427
- app.get(path, ...auth, mcpGet);
428
- app.delete(path, ...auth, mcpDelete);
434
+ app.post(path, ...mcpAuth, mcpPost);
435
+ app.get(path, ...mcpAuth, mcpGet);
436
+ app.delete(path, ...mcpAuth, mcpDelete);
429
437
  }
430
438
 
431
439
  // ─── Catch-all: log unhandled requests ──────────────────────────
@@ -478,7 +486,7 @@ export function startHttpServer(port = 3100) {
478
486
  * If the user previously called ateam_auth to override (e.g., switch tenants),
479
487
  * that override is stored per bearer and takes priority here.
480
488
  */
481
- function seedCredentials(req, sessionId) {
489
+ async function seedCredentials(req, sessionId) {
482
490
  const token = req.auth?.token;
483
491
  if (!token) return;
484
492
 
@@ -501,7 +509,35 @@ function seedCredentials(req, sessionId) {
501
509
  // redundant ateam_auth call. (The env-var guard in tools.js is unaffected —
502
510
  // env creds never flow through here; this path only fires for a real Bearer.)
503
511
  const parsed = parseApiKey(token);
504
- if (parsed.isValid) {
505
- setSessionCredentials(sessionId, { tenant: parsed.tenant, apiKey: token, explicit: true });
512
+ if (!parsed.isValid) return;
513
+
514
+ // The key's own environment decides the base. Without this a dev bearer
515
+ // silently used the process default, which is PRODUCTION — the same
516
+ // never-guess-the-environment rule ateam_auth already follows, applied to the
517
+ // path that had been missed.
518
+ const apiUrl = baseUrlForKeyEnv(token) || undefined;
519
+
520
+ if (parsed.tenant) {
521
+ setSessionCredentials(sessionId, { tenant: parsed.tenant, apiKey: token, apiUrl, explicit: true });
522
+ return;
523
+ }
524
+
525
+ // A SEALED key does not spell out its tenant, so it has to be asked for —
526
+ // ONCE. This runs on every request for an existing session, so without the
527
+ // guard below it would be a network round trip per MCP call.
528
+ const current = (() => { try { return getCredentials(sessionId); } catch { return null; } })();
529
+ if (current?.tenant && current.apiKey === token) return;
530
+
531
+ try {
532
+ const me = await whoami(token, apiUrl);
533
+ setSessionCredentials(sessionId, { tenant: me.tenant, apiKey: token, apiUrl, explicit: true });
534
+ } catch (err) {
535
+ // Credentials are still set: the tenant lives INSIDE the key and Core reads
536
+ // it, so calls authenticate correctly with no X-ADAS-TENANT header at all.
537
+ // What we must not do is fill the gap with a guess — an unresolved tenant
538
+ // is recorded as null and retried on the next request. Anything that needs
539
+ // the name will say it does not have it.
540
+ console.warn(`[Auth] whoami failed for session ${sessionId} at ${apiUrl}: ${err.message} — proceeding with the tenant unresolved, never assumed.`);
541
+ setSessionCredentials(sessionId, { tenant: null, apiKey: token, apiUrl, explicit: true });
506
542
  }
507
543
  }
package/src/tools.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import {
12
12
  get, post, patch, del,
13
13
  setSessionCredentials, isAuthenticated, isExplicitlyAuthenticated,
14
- getCredentials, parseApiKey, touchSession, getSessionContext,
14
+ getCredentials, parseApiKey, whoami, baseUrlForKeyEnv, envForBaseUrl, touchSession, getSessionContext,
15
15
  setAuthOverride, switchTenant, isMasterMode, listTenants, getWhere, getBaseUrl,
16
16
  } from "./api.js";
17
17
 
@@ -3528,22 +3528,76 @@ export const handlers = {
3528
3528
  if (!api_key) {
3529
3529
  return { ok: false, message: "Provide either api_key or master_key." };
3530
3530
  }
3531
- // Auto-extract tenant from key if not provided.
3532
- // Fail loudly if neither the explicit tenant arg nor a parseable apiKey
3533
- // yields a tenant previously fell back to "main" silently.
3534
- let resolvedTenant = tenant;
3531
+ // ── THE KEY NAMES ITS ENVIRONMENT ──────────────────────────────────────
3532
+ //
3533
+ // One public MCP endpoint, and until now nothing about a session said which
3534
+ // environment it was on: the caller passed `url` or silently got the prod
3535
+ // default. A dev key at the prod base is just a 401, diagnosed after the
3536
+ // fact (see the hint below) and never prevented. The process starts at the
3537
+ // key, so the key carries the environment.
3538
+ //
3539
+ // NO FALLBACK, in either direction. A key that names an environment routes
3540
+ // there and NOWHERE else; if that backend rejects it, that is the answer.
3541
+ // Retrying the sibling host is how a dev key deploys to production.
3542
+ const keyEnv = parseApiKey(api_key).env;
3543
+ const explicitUrl = url ? url.replace(/\/+$/, "") : undefined;
3544
+
3545
+ // An explicit url that CONTRADICTS the key is refused here, locally, before
3546
+ // any network call. The override still exists for unusual hosts (localhost,
3547
+ // a staging box) — envForBaseUrl only recognises the known prod/dev hosts,
3548
+ // so anything else passes through untouched. What it must never be is a way
3549
+ // to cross environments by accident.
3550
+ if (keyEnv && explicitUrl) {
3551
+ const urlEnv = envForBaseUrl(explicitUrl);
3552
+ if (urlEnv && urlEnv !== keyEnv) {
3553
+ return {
3554
+ ok: false,
3555
+ message: `Refusing to authenticate: this key names the "${keyEnv}" environment, but url points at "${urlEnv}" (${explicitUrl}). One of them is wrong, and guessing which would mean operating on the wrong system. Drop the url argument to use the key's own environment, or use a key for "${urlEnv}".`,
3556
+ };
3557
+ }
3558
+ }
3559
+
3560
+ const apiUrl = explicitUrl || baseUrlForKeyEnv(api_key) || undefined;
3561
+
3562
+ // ── WHO IS THIS KEY? ───────────────────────────────────────────────────
3563
+ //
3564
+ // It used to be answered by splitting the string, which is exactly why the
3565
+ // customer's name travelled inside the credential — into logs, screenshots,
3566
+ // support tickets. A SEALED key (`adas_<env>_<blob>`) does not carry it, so
3567
+ // we ASK. Order of preference, and nothing beyond it:
3568
+ //
3569
+ // 1. an explicit `tenant` argument
3570
+ // 2. the tenant the key still spells out (older formats)
3571
+ // 3. GET /auth/whoami
3572
+ //
3573
+ // A sealed key whose whoami fails is REFUSED. Not "authenticated without a
3574
+ // tenant", not retried elsewhere: ateam_auth is the moment a session learns
3575
+ // who it is, and half-knowing is how a caller ends up acting on the wrong
3576
+ // account. Nothing here invents a tenant under any circumstances.
3577
+ let resolvedTenant = tenant || parseApiKey(api_key).tenant;
3535
3578
  if (!resolvedTenant) {
3536
- const parsed = parseApiKey(api_key);
3537
- resolvedTenant = parsed.tenant;
3579
+ const base = apiUrl || getBaseUrl(sessionId);
3580
+ try {
3581
+ const me = await whoami(api_key, base);
3582
+ resolvedTenant = me.tenant;
3583
+ } catch (err) {
3584
+ return {
3585
+ ok: false,
3586
+ message:
3587
+ `This key does not name its tenant — the tenant is sealed inside it and only the server can read it — ` +
3588
+ `and ${base} could not tell me who you are: ${err.message} ` +
3589
+ `Nothing was authenticated: acting on a guessed tenant is the one failure this must never have. ` +
3590
+ `If that host is an older deployment without /auth/whoami, upgrade it or pass tenant: "<name>" explicitly.`,
3591
+ };
3592
+ }
3538
3593
  }
3539
3594
  if (!resolvedTenant) {
3540
3595
  return {
3541
3596
  ok: false,
3542
- message: `Could not resolve tenant from api_key (expected format: adas_<tenant>_<32hex>). Pass the "tenant" arg explicitly, or check that your API key is well-formed.`,
3597
+ message: `Could not resolve tenant from api_key (expected format: adas_<env>_<key>). Pass the "tenant" arg explicitly, or check that your API key is well-formed.`,
3543
3598
  };
3544
3599
  }
3545
- // Normalize URL: strip trailing slash
3546
- const apiUrl = url ? url.replace(/\/+$/, "") : undefined;
3600
+
3547
3601
  setSessionCredentials(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl, explicit: true });
3548
3602
  // Persist override per bearer (survives session changes)
3549
3603
  setAuthOverride(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl });
@@ -3554,6 +3608,23 @@ export const handlers = {
3554
3608
  return {
3555
3609
  ok: true,
3556
3610
  tenant: resolvedTenant,
3611
+ // The environment is part of WHO YOU ARE NOW, so it is reported here and
3612
+ // in ateam_bootstrap.runtime, from the same resolution — one question,
3613
+ // one answer.
3614
+ //
3615
+ // A KEY THAT NAMES NO ENVIRONMENT GETS NO CLAIM. Until keys are
3616
+ // recreated, a legacy `adas_<tenant>_<hex>` still authenticates and
3617
+ // still lands on the process default — which is PRODUCTION. That
3618
+ // routing predates this change and is not made worse by it, but
3619
+ // reporting it as `environment: "prod"` would be: it would turn an
3620
+ // unstated default into a confident assertion, which is the exact
3621
+ // failure this whole change exists to remove. So the field says
3622
+ // `unstated`, and the note says which base was used and why.
3623
+ environment: keyEnv || (explicitUrl ? envForBaseUrl(explicitUrl) : null) || "unstated",
3624
+ ...(!keyEnv && !explicitUrl && {
3625
+ environment_note: `This key does not name an environment, so the process default was used (${getBaseUrl(sessionId)}). Recreate it as adas_<env>_<tenant>_<hex> to make the environment explicit — until then nothing here can confirm which system you are on.`,
3626
+ }),
3627
+ base_url: getBaseUrl(sessionId),
3557
3628
  message: `Authenticated to tenant "${resolvedTenant}"${urlNote}. ${result.solutions?.length || 0} solution(s) found.`,
3558
3629
  };
3559
3630
  } catch (err) {
@@ -3562,7 +3633,8 @@ export const handlers = {
3562
3633
  // versa). Surface the base we tried and, if it looks like that mismatch,
3563
3634
  // hint the dev-api retry — instead of a generic "invalid/unconfigured key".
3564
3635
  const base = getBaseUrl(sessionId) || "";
3565
- const wellFormedKey = parseApiKey(api_key).isValid;
3636
+ const parsedKey = parseApiKey(api_key);
3637
+ const wellFormedKey = parsedKey.isValid;
3566
3638
  const triedProd = /(?:^|\/\/)api\.ateam-ai\.com/.test(base);
3567
3639
  // THE HEADLINE MUST NOT CONTRADICT THE HINT. The upstream message is
3568
3640
  // "Invalid or unconfigured API key" — which for a well-formed key tried
@@ -3571,7 +3643,12 @@ export const handlers = {
3571
3643
  // did not need. The hint below already said the right thing and rescued a
3572
3644
  // session on 2026-08-21, but only because someone read past the first
3573
3645
  // line. Lead with the likely cause; keep the upstream text as detail.
3574
- if (wellFormedKey && triedProd) {
3646
+ // A key that NAMES its environment cannot be in the wrong one — routing
3647
+ // came from the key itself, and a contradicting url was refused above. So
3648
+ // this hint is only for the older no-env keys that still land on the prod
3649
+ // default. Offering it for an env-bearing key would send the reader
3650
+ // chasing an environment mismatch that the format has already ruled out.
3651
+ if (wellFormedKey && triedProd && !parsedKey.env) {
3575
3652
  return {
3576
3653
  ok: false,
3577
3654
  tenant: resolvedTenant,