@ateam-ai/mcp 0.4.90 → 0.4.92

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.90",
3
+ "version": "0.4.92",
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"
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";
@@ -302,7 +302,7 @@ export function startHttpServer(port = 3100) {
302
302
  if (sessionId && transports[sessionId]) {
303
303
  // Reuse existing session — seed credentials if Bearer token present
304
304
  transport = transports[sessionId];
305
- seedCredentials(req, sessionId);
305
+ await seedCredentials(req, sessionId);
306
306
  } else if (isInitializeRequest(req.body) || (sessionId && !transports[sessionId])) {
307
307
  // New session, OR stale session with any request type (server restart recovery).
308
308
  // Many MCP clients (Claude mobile, Claude Code) cache the session ID and fail to
@@ -326,7 +326,7 @@ export function startHttpServer(port = 3100) {
326
326
  const newSessionId = sessionId || randomUUID();
327
327
 
328
328
  // Seed credentials from OAuth Bearer token before server starts
329
- seedCredentials(req, newSessionId);
329
+ await seedCredentials(req, newSessionId);
330
330
 
331
331
  transport = new StreamableHTTPServerTransport({
332
332
  sessionIdGenerator: () => newSessionId,
@@ -478,7 +478,7 @@ export function startHttpServer(port = 3100) {
478
478
  * If the user previously called ateam_auth to override (e.g., switch tenants),
479
479
  * that override is stored per bearer and takes priority here.
480
480
  */
481
- function seedCredentials(req, sessionId) {
481
+ async function seedCredentials(req, sessionId) {
482
482
  const token = req.auth?.token;
483
483
  if (!token) return;
484
484
 
@@ -501,7 +501,35 @@ function seedCredentials(req, sessionId) {
501
501
  // redundant ateam_auth call. (The env-var guard in tools.js is unaffected —
502
502
  // env creds never flow through here; this path only fires for a real Bearer.)
503
503
  const parsed = parseApiKey(token);
504
- if (parsed.isValid) {
505
- setSessionCredentials(sessionId, { tenant: parsed.tenant, apiKey: token, explicit: true });
504
+ if (!parsed.isValid) return;
505
+
506
+ // The key's own environment decides the base. Without this a dev bearer
507
+ // silently used the process default, which is PRODUCTION — the same
508
+ // never-guess-the-environment rule ateam_auth already follows, applied to the
509
+ // path that had been missed.
510
+ const apiUrl = baseUrlForKeyEnv(token) || undefined;
511
+
512
+ if (parsed.tenant) {
513
+ setSessionCredentials(sessionId, { tenant: parsed.tenant, apiKey: token, apiUrl, explicit: true });
514
+ return;
515
+ }
516
+
517
+ // A SEALED key does not spell out its tenant, so it has to be asked for —
518
+ // ONCE. This runs on every request for an existing session, so without the
519
+ // guard below it would be a network round trip per MCP call.
520
+ const current = (() => { try { return getCredentials(sessionId); } catch { return null; } })();
521
+ if (current?.tenant && current.apiKey === token) return;
522
+
523
+ try {
524
+ const me = await whoami(token, apiUrl);
525
+ setSessionCredentials(sessionId, { tenant: me.tenant, apiKey: token, apiUrl, explicit: true });
526
+ } catch (err) {
527
+ // Credentials are still set: the tenant lives INSIDE the key and Core reads
528
+ // it, so calls authenticate correctly with no X-ADAS-TENANT header at all.
529
+ // What we must not do is fill the gap with a guess — an unresolved tenant
530
+ // is recorded as null and retried on the next request. Anything that needs
531
+ // the name will say it does not have it.
532
+ console.warn(`[Auth] whoami failed for session ${sessionId} at ${apiUrl}: ${err.message} — proceeding with the tenant unresolved, never assumed.`);
533
+ setSessionCredentials(sessionId, { tenant: null, apiKey: token, apiUrl, explicit: true });
506
534
  }
507
535
  }
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
 
@@ -3176,11 +3176,25 @@ function chainTreeOf(resp) {
3176
3176
  // is the difference between proving behaviour and matching a string that a
3177
3177
  // rename would quietly satisfy.
3178
3178
  export const handlers = {
3179
- ateam_bootstrap: async () => ({
3179
+ // (_args, sid) the SESSION ID IS LOAD-BEARING HERE.
3180
+ //
3181
+ // getBaseUrl(sessionId) resolves per-session first (api.js:326-339): a caller
3182
+ // that passed `url` to ateam_auth is talking to THAT api, and the session
3183
+ // holds it. Bootstrap called it with NO argument, so it skipped the
3184
+ // per-session and bearer branches every time and reported the process
3185
+ // DEFAULT — https://api.ateam-ai.com.
3186
+ //
3187
+ // The result was a tool that says PROD while the session is authenticated to
3188
+ // DEV, in the one field whose whole job is to tell you which environment you
3189
+ // are on. Observed live: a session working entirely against
3190
+ // dev-api.ateam-ai.com was told base_url: https://api.ateam-ai.com, and had
3191
+ // to learn from deploy errors which environment it was actually on. The
3192
+ // dangerous direction is the mirror image — believing you are on dev.
3193
+ ateam_bootstrap: async (_args, sid) => ({
3180
3194
  runtime: {
3181
3195
  ateam_mcp_version: MCP_VERSION,
3182
- base_url: getBaseUrl(),
3183
- _note: "The version of the ateam-mcp process actually serving this call, and the API it talks to. If a fix looks missing, check this FIRST — a local MCP process keeps running the code it loaded at session start, so a pushed/published fix is not live until the process restarts.",
3196
+ base_url: getBaseUrl(sid),
3197
+ _note: "The version of the ateam-mcp process actually serving this call, and the API THIS SESSION talks to (per-session, as set by ateam_auth's `url`). If a fix looks missing, check this FIRST — a local MCP process keeps running the code it loaded at session start, so a pushed/published fix is not live until the process restarts.",
3184
3198
  },
3185
3199
  platform_positioning: {
3186
3200
  name: "A-Team",
@@ -3514,22 +3528,76 @@ export const handlers = {
3514
3528
  if (!api_key) {
3515
3529
  return { ok: false, message: "Provide either api_key or master_key." };
3516
3530
  }
3517
- // Auto-extract tenant from key if not provided.
3518
- // Fail loudly if neither the explicit tenant arg nor a parseable apiKey
3519
- // yields a tenant previously fell back to "main" silently.
3520
- 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;
3521
3578
  if (!resolvedTenant) {
3522
- const parsed = parseApiKey(api_key);
3523
- 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
+ }
3524
3593
  }
3525
3594
  if (!resolvedTenant) {
3526
3595
  return {
3527
3596
  ok: false,
3528
- 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.`,
3529
3598
  };
3530
3599
  }
3531
- // Normalize URL: strip trailing slash
3532
- const apiUrl = url ? url.replace(/\/+$/, "") : undefined;
3600
+
3533
3601
  setSessionCredentials(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl, explicit: true });
3534
3602
  // Persist override per bearer (survives session changes)
3535
3603
  setAuthOverride(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl });
@@ -3540,6 +3608,23 @@ export const handlers = {
3540
3608
  return {
3541
3609
  ok: true,
3542
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),
3543
3628
  message: `Authenticated to tenant "${resolvedTenant}"${urlNote}. ${result.solutions?.length || 0} solution(s) found.`,
3544
3629
  };
3545
3630
  } catch (err) {
@@ -3548,7 +3633,8 @@ export const handlers = {
3548
3633
  // versa). Surface the base we tried and, if it looks like that mismatch,
3549
3634
  // hint the dev-api retry — instead of a generic "invalid/unconfigured key".
3550
3635
  const base = getBaseUrl(sessionId) || "";
3551
- const wellFormedKey = parseApiKey(api_key).isValid;
3636
+ const parsedKey = parseApiKey(api_key);
3637
+ const wellFormedKey = parsedKey.isValid;
3552
3638
  const triedProd = /(?:^|\/\/)api\.ateam-ai\.com/.test(base);
3553
3639
  // THE HEADLINE MUST NOT CONTRADICT THE HINT. The upstream message is
3554
3640
  // "Invalid or unconfigured API key" — which for a well-formed key tried
@@ -3557,7 +3643,12 @@ export const handlers = {
3557
3643
  // did not need. The hint below already said the right thing and rescued a
3558
3644
  // session on 2026-08-21, but only because someone read past the first
3559
3645
  // line. Lead with the likely cause; keep the upstream text as detail.
3560
- 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) {
3561
3652
  return {
3562
3653
  ok: false,
3563
3654
  tenant: resolvedTenant,