@topolo/cli 0.13.22 → 0.13.24

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/dist/index.js CHANGED
@@ -308,7 +308,7 @@ import { Command, Help } from "commander";
308
308
  import { createInterface } from "readline/promises";
309
309
  import { stdin as input } from "process";
310
310
 
311
- // node_modules/@topolo/sdk/dist/index.js
311
+ // node_modules/.pnpm/@topolo+sdk@0.23.26/node_modules/@topolo/sdk/dist/index.js
312
312
  function record(value) {
313
313
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
314
314
  }
@@ -815,6 +815,14 @@ var TopoloRateLimitError = class extends TopoloHttpError {
815
815
  this.retryAfterSeconds = topoloRetryAfterSeconds(this.retryAfter);
816
816
  }
817
817
  };
818
+ var TopoloReauthRequiredError = class extends TopoloAuthError {
819
+ reauthorization;
820
+ constructor(message3, reauthorization, options = {}) {
821
+ super(message3, "reauth_required", { status: 401, service: "auth", path: "/api/developer-oauth/token", ...options });
822
+ this.name = "TopoloReauthRequiredError";
823
+ this.reauthorization = Object.freeze({ ...reauthorization });
824
+ }
825
+ };
818
826
  var ACTION_SECRET_PREFIX = "topolo-secret:";
819
827
  function isActionSecretReference(value) {
820
828
  return typeof value === "string" && value.startsWith(ACTION_SECRET_PREFIX);
@@ -1968,7 +1976,7 @@ var TopoloClient = class _TopoloClient {
1968
1976
  } catch (error) {
1969
1977
  const transportFailure = !(error instanceof TopoloSdkError) && !opts.signal?.aborted;
1970
1978
  if (!transportFailure || attempt === maxAttempts) throw error;
1971
- await new Promise((resolve5) => setTimeout(resolve5, 250));
1979
+ await new Promise((resolve6) => setTimeout(resolve6, 250));
1972
1980
  }
1973
1981
  }
1974
1982
  throw new TopoloSdkError("network_error", "Topolo read retry loop exhausted.");
@@ -1991,7 +1999,7 @@ var TopoloClient = class _TopoloClient {
1991
1999
  const delayMs = header === void 0 ? RATE_LIMIT_READ_DEFAULT_DELAY_MS * (retry + 1) : (seconds ?? Infinity) * 1e3;
1992
2000
  if (delayMs > RATE_LIMIT_READ_RETRY_MAX_DELAY_MS || opts.signal?.aborted) break;
1993
2001
  await res.body?.cancel().catch(() => void 0);
1994
- await new Promise((resolve5) => setTimeout(resolve5, delayMs));
2002
+ await new Promise((resolve6) => setTimeout(resolve6, delayMs));
1995
2003
  res = await fetchWithReadRetry();
1996
2004
  }
1997
2005
  } catch (err) {
@@ -2115,7 +2123,10 @@ var TopoloClient = class _TopoloClient {
2115
2123
  this.refreshInFlight ??= this.refreshCredential().then((credential) => {
2116
2124
  if (credential) this.credential = credential;
2117
2125
  return credential;
2118
- }).catch(() => null).finally(() => {
2126
+ }).catch((error) => {
2127
+ if (error instanceof TopoloReauthRequiredError) throw error;
2128
+ return null;
2129
+ }).finally(() => {
2119
2130
  this.refreshInFlight = null;
2120
2131
  });
2121
2132
  return this.refreshInFlight;
@@ -3489,6 +3500,19 @@ var TopoloOAuth = class {
3489
3500
  if (params.clientSecret) body.set("client_secret", params.clientSecret);
3490
3501
  return this.#postForm("/api/developer-oauth/token", body);
3491
3502
  }
3503
+ /**
3504
+ * RFC 7009 token revocation. A refresh token is revoked only for the client
3505
+ * it was issued to, so pass that client's id. Auth answers 200 for a token it
3506
+ * does not know, so success means "no longer usable", not "was revoked now".
3507
+ */
3508
+ async revokeToken(params) {
3509
+ const body = new URLSearchParams();
3510
+ body.set("token", params.token);
3511
+ body.set("client_id", params.clientId);
3512
+ if (params.tokenTypeHint) body.set("token_type_hint", params.tokenTypeHint);
3513
+ if (params.clientSecret) body.set("client_secret", params.clientSecret);
3514
+ await this.#postForm("/api/tokens/revoke", body);
3515
+ }
3492
3516
  async #postForm(path, body) {
3493
3517
  const url = new URL(path, this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`);
3494
3518
  const res = await this.fetchImpl(url.toString(), {
@@ -3989,7 +4013,7 @@ function createTopolo(options) {
3989
4013
  async function pollUntilApproved(params) {
3990
4014
  let interval = Math.max(params.intervalSeconds ?? 5, 1);
3991
4015
  while (Date.now() < params.expiresAtMs) {
3992
- await new Promise((resolve5) => setTimeout(resolve5, Math.min(interval * 1e3, params.expiresAtMs - Date.now())));
4016
+ await new Promise((resolve6) => setTimeout(resolve6, Math.min(interval * 1e3, params.expiresAtMs - Date.now())));
3993
4017
  if (Date.now() >= params.expiresAtMs) break;
3994
4018
  try {
3995
4019
  return await params.oauth.pollDeviceToken({
@@ -4017,32 +4041,16 @@ async function pollUntilApproved(params) {
4017
4041
  throw new Error("Device code expired. Run `topolo auth login` again.");
4018
4042
  }
4019
4043
 
4020
- // src/lib/open-browser.ts
4021
- import { spawn } from "child_process";
4022
- import { platform } from "os";
4023
- function tryOpenBrowser(url) {
4024
- try {
4025
- const os = platform();
4026
- const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
4027
- const args = os === "win32" ? ["", url] : [url];
4028
- const child = spawn(cmd, args, {
4029
- detached: true,
4030
- stdio: "ignore",
4031
- ...os === "win32" ? { shell: true } : {}
4032
- });
4033
- child.unref();
4034
- return true;
4035
- } catch {
4036
- return false;
4037
- }
4038
- }
4044
+ // src/commands/auth-lock.ts
4045
+ import { createHash as createHash3 } from "crypto";
4039
4046
 
4040
- // node_modules/@topolo/sdk/dist/node/credential-store.js
4047
+ // node_modules/.pnpm/@topolo+sdk@0.23.26/node_modules/@topolo/sdk/dist/node/credential-store.js
4041
4048
  import { mkdir as mkdir2, readFile as readFile2, chmod as chmod2, lstat, readdir as readdir2 } from "fs/promises";
4042
4049
  import { existsSync as existsSync2, mkdirSync, lstatSync, readdirSync, readFileSync as readFileSync2, openSync, writeFileSync, fsyncSync, closeSync, renameSync, unlinkSync, rmdirSync, linkSync } from "fs";
4043
4050
  import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
4044
4051
  import { constants, fstatSync, readSync } from "fs";
4045
- import { homedir as homedir2, platform as platform2 } from "os";
4052
+ import { resolve as resolve2 } from "path";
4053
+ import { homedir as homedir2, platform } from "os";
4046
4054
  import { dirname as dirname2, join as join3 } from "path";
4047
4055
  import { createHash, randomUUID } from "crypto";
4048
4056
  import { join as join2 } from "path";
@@ -4370,7 +4378,7 @@ var TopoloRateLimitError2 = class extends TopoloHttpError2 {
4370
4378
  this.retryAfterSeconds = topoloRetryAfterSeconds2(this.retryAfter);
4371
4379
  }
4372
4380
  };
4373
- var TopoloReauthRequiredError = class extends TopoloAuthError2 {
4381
+ var TopoloReauthRequiredError2 = class extends TopoloAuthError2 {
4374
4382
  reauthorization;
4375
4383
  constructor(message3, reauthorization, options = {}) {
4376
4384
  super(message3, "reauth_required", { status: 401, service: "auth", path: "/api/developer-oauth/token", ...options });
@@ -4520,6 +4528,19 @@ var TopoloOAuth2 = class {
4520
4528
  if (params.clientSecret) body.set("client_secret", params.clientSecret);
4521
4529
  return this.#postForm("/api/developer-oauth/token", body);
4522
4530
  }
4531
+ /**
4532
+ * RFC 7009 token revocation. A refresh token is revoked only for the client
4533
+ * it was issued to, so pass that client's id. Auth answers 200 for a token it
4534
+ * does not know, so success means "no longer usable", not "was revoked now".
4535
+ */
4536
+ async revokeToken(params) {
4537
+ const body = new URLSearchParams();
4538
+ body.set("token", params.token);
4539
+ body.set("client_id", params.clientId);
4540
+ if (params.tokenTypeHint) body.set("token_type_hint", params.tokenTypeHint);
4541
+ if (params.clientSecret) body.set("client_secret", params.clientSecret);
4542
+ await this.#postForm("/api/tokens/revoke", body);
4543
+ }
4523
4544
  async #postForm(path, body) {
4524
4545
  const url = new URL(path, this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`);
4525
4546
  const res = await this.fetchImpl(url.toString(), {
@@ -4810,7 +4831,7 @@ var ENVIRONMENT_DEFAULT_SERVICE_URLS = TOPOLO_ENVIRONMENT_SERVICE_URLS;
4810
4831
  function configPath() {
4811
4832
  const override = process.env.TOPOLO_CONFIG_PATH;
4812
4833
  if (override) return override;
4813
- const base = process.env.XDG_CONFIG_HOME || (platform2() === "win32" ? join3(homedir2(), "AppData", "Roaming") : join3(homedir2(), ".config"));
4834
+ const base = process.env.XDG_CONFIG_HOME || (platform() === "win32" ? join3(homedir2(), "AppData", "Roaming") : join3(homedir2(), ".config"));
4814
4835
  return join3(base, "topolo", "config.json");
4815
4836
  }
4816
4837
  async function readConfig() {
@@ -4818,18 +4839,22 @@ async function readConfig() {
4818
4839
  if (!existsSync2(path)) return {};
4819
4840
  try {
4820
4841
  const raw = await readFile2(path, "utf8");
4821
- return JSON.parse(raw);
4842
+ const parsed = JSON.parse(raw);
4843
+ return isConfigObject(parsed) ? parsed : {};
4822
4844
  } catch {
4823
4845
  return {};
4824
4846
  }
4825
4847
  }
4848
+ function isConfigObject(value) {
4849
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
4850
+ }
4826
4851
  async function writeConfig(config) {
4827
4852
  const path = configPath();
4828
4853
  await mkdir2(dirname2(path), { recursive: true });
4829
4854
  await withConfigWriteLock(async (_assertOwned, persist) => {
4830
4855
  await persist((current) => protectStoredGrants(config, current), { allowCreate: true });
4831
4856
  });
4832
- if (platform2() !== "win32") {
4857
+ if (platform() !== "win32") {
4833
4858
  try {
4834
4859
  await chmod2(path, 384);
4835
4860
  } catch {
@@ -4854,10 +4879,21 @@ function retiredEntry(config, refreshToken) {
4854
4879
  const entry = config.retiredRefreshTokens?.[refreshTokenFingerprint(refreshToken)];
4855
4880
  return entry && typeof entry === "object" ? entry : void 0;
4856
4881
  }
4857
- function retire(config, refreshToken, reason) {
4882
+ function retire(config, refreshToken, reason, inFlight = false) {
4858
4883
  const now = nowSeconds();
4859
- const entries = Object.entries({ ...config.retiredRefreshTokens ?? {}, [refreshTokenFingerprint(refreshToken)]: { reason, at: now } }).filter(([key, value]) => /^[a-f0-9]{32}$/.test(key) && value && Number.isFinite(value.at) && value.at > now - RETIRED_RETENTION_SECONDS).sort(([, a], [, b]) => b.at - a.at).slice(0, RETIRED_MAX_ENTRIES);
4860
- config.retiredRefreshTokens = Object.fromEntries(entries);
4884
+ const entry = inFlight ? { reason, at: now, inFlight: true } : { reason, at: now };
4885
+ const held = new Set(allSlots(config).map(slotRefreshToken).filter((token) => Boolean(token)).map(refreshTokenFingerprint));
4886
+ const valid = Object.entries({ ...config.retiredRefreshTokens ?? {}, [refreshTokenFingerprint(refreshToken)]: entry }).filter(([key, value]) => /^[a-f0-9]{32}$/.test(key) && value && Number.isFinite(value.at));
4887
+ const kept = valid.filter(([key]) => held.has(key));
4888
+ const others = valid.filter(([key, value]) => !held.has(key) && value.at > now - RETIRED_RETENTION_SECONDS).sort(([, a], [, b]) => b.at - a.at).slice(0, Math.max(0, RETIRED_MAX_ENTRIES - kept.length));
4889
+ config.retiredRefreshTokens = Object.fromEntries([...kept, ...others]);
4890
+ }
4891
+ function clearInFlight(config, refreshToken) {
4892
+ const fingerprint = refreshTokenFingerprint(refreshToken);
4893
+ if (!config.retiredRefreshTokens?.[fingerprint]?.inFlight) return;
4894
+ const { [fingerprint]: _withdrawn, ...rest } = config.retiredRefreshTokens;
4895
+ if (Object.keys(rest).length) config.retiredRefreshTokens = rest;
4896
+ else delete config.retiredRefreshTokens;
4861
4897
  }
4862
4898
  function environmentSlots(config, environment) {
4863
4899
  const slots = [];
@@ -4939,6 +4975,20 @@ function loginCommand(environment, key) {
4939
4975
  const use = environment === "production" ? "topolo context use" : `topolo --env ${environment} context use`;
4940
4976
  return `${base} && ${use} ${key.slice(4)}`;
4941
4977
  }
4978
+ function slotUsers(profile, key) {
4979
+ const context = key === DEFAULT_SLOT ? void 0 : profile.contexts?.[key];
4980
+ const credentials = [context?.credential, key === DEFAULT_SLOT || profile.activeContext === key ? profile.credential : void 0];
4981
+ const users = [
4982
+ context?.userId,
4983
+ ...credentials.map((credential) => credential?.kind === "access_token" ? accessTokenSubject(credential.accessToken) : void 0),
4984
+ profile.reauthorization?.[key]?.expectedSubject
4985
+ ];
4986
+ return new Set(users.filter((user) => typeof user === "string" && user.length > 0));
4987
+ }
4988
+ function mismatchState(entry) {
4989
+ if (!entry.mismatch) return "unavailable";
4990
+ return entry.mismatch.differentUser ? "user_mismatch" : "organization_mismatch";
4991
+ }
4942
4992
  function contextLabel(key) {
4943
4993
  return key === DEFAULT_SLOT ? "default" : key;
4944
4994
  }
@@ -4950,6 +5000,8 @@ function reauthRequired(environment, key, entry, state) {
4950
5000
  let action;
4951
5001
  if (state === "awaiting_approval" && pending) {
4952
5002
  action = ` Approve the new sign-in: open ${pending.verificationUriComplete ?? pending.verificationUri} and confirm code ${pending.userCode} (expires ${new Date(pending.expiresAt * 1e3).toISOString()}). The next call resumes once approved.`;
5003
+ } else if (state === "user_mismatch") {
5004
+ action = ` The last approval was by a different user than the one signed in to ${contextLabel(key)}; it was discarded. Approve again as that user, or sign out first to replace this sign-in.`;
4953
5005
  } else if (state === "organization_mismatch") {
4954
5006
  action = ` The last approval was for ${entry?.mismatch?.approvedContext ?? "another context"}, not ${contextLabel(key)}; approve again with that organization selected.`;
4955
5007
  } else if (state === "denied") {
@@ -4957,7 +5009,7 @@ function reauthRequired(environment, key, entry, state) {
4957
5009
  } else {
4958
5010
  action = "";
4959
5011
  }
4960
- return new TopoloReauthRequiredError(`${head}${action} Or sign in again: ${command} (context ${contextLabel(key)}).`, {
5012
+ return new TopoloReauthRequiredError2(`${head}${action} Or sign in again: ${command} (context ${contextLabel(key)}).`, {
4961
5013
  environment,
4962
5014
  context: contextLabel(key),
4963
5015
  reason,
@@ -5035,18 +5087,94 @@ function writeContextCredential(config, environment, ctx, makeActive = true) {
5035
5087
  }
5036
5088
  return writeProfileConfig(config, environment, next);
5037
5089
  }
5038
- function clearProfileCredential(config, environment) {
5039
- const next = { ...config };
5090
+ function clearEnvironmentSignIn(config, environment) {
5091
+ const next = clone(config);
5040
5092
  if (environment === "production") {
5041
5093
  delete next.credential;
5042
5094
  delete next.oauthClientId;
5043
5095
  }
5044
- const profiles = { ...config.profiles ?? {} };
5045
- const profile = { ...profiles[environment] ?? {} };
5046
- delete profile.credential;
5047
- delete profile.oauthClientId;
5048
- profiles[environment] = profile;
5049
- return { ...next, profiles };
5096
+ const profile = next.profiles?.[environment];
5097
+ if (profile) {
5098
+ delete profile.credential;
5099
+ delete profile.oauthClientId;
5100
+ delete profile.activeContext;
5101
+ delete profile.contexts;
5102
+ delete profile.reauthorization;
5103
+ }
5104
+ return next;
5105
+ }
5106
+ async function signOut(environment, options = {}) {
5107
+ const result = { environment, contexts: [], revoked: 0, revocationFailed: 0 };
5108
+ if (!existsSync2(configPath())) return result;
5109
+ let grants = [];
5110
+ let profile = {};
5111
+ const collect = (config, found, keys) => {
5112
+ const owner = profileForEnvironment(config, environment);
5113
+ for (const slot of environmentSlots(config, environment)) {
5114
+ const credential = slot.holder.credential;
5115
+ if (!credential) continue;
5116
+ keys?.add(slot.key);
5117
+ if (credential.kind !== "access_token") continue;
5118
+ const clientId = slot.holder.oauthClientId ?? owner.oauthClientId;
5119
+ found.set(credential.accessToken, {
5120
+ accessToken: credential.accessToken,
5121
+ ...credential.refreshToken ? { refreshToken: credential.refreshToken } : {},
5122
+ ...clientId ? { clientId } : {}
5123
+ });
5124
+ }
5125
+ };
5126
+ await withConfigWriteLock(async (_assertOwned, persist) => {
5127
+ let found = /* @__PURE__ */ new Map();
5128
+ await persist((current) => {
5129
+ profile = profileForEnvironment(current, environment);
5130
+ const keys = /* @__PURE__ */ new Set();
5131
+ found = /* @__PURE__ */ new Map();
5132
+ collect(current, found, keys);
5133
+ for (const key of Object.keys(current.profiles?.[environment]?.reauthorization ?? {})) keys.add(key);
5134
+ result.contexts = [...keys].map(contextLabel).sort();
5135
+ return clearEnvironmentSignIn(current, environment);
5136
+ });
5137
+ const directory = dirname2(configPath());
5138
+ for (const name of readdirSync(directory)) {
5139
+ if (!name.startsWith(".credential-write-")) continue;
5140
+ const path = join3(directory, name);
5141
+ try {
5142
+ const entry = lstatSync(path);
5143
+ if (!entry.isFile() || entry.nlink !== 1) continue;
5144
+ try {
5145
+ const leftover = JSON.parse(readFileSync2(path, "utf8"));
5146
+ if (isConfigObject(leftover)) collect(leftover, found);
5147
+ } catch {
5148
+ }
5149
+ unlinkSync(path);
5150
+ } catch {
5151
+ }
5152
+ }
5153
+ grants = [...found.values()];
5154
+ });
5155
+ if (options.revoke === false || !grants.length) return result;
5156
+ let oauth;
5157
+ try {
5158
+ oauth = boundedOAuth(authServiceUrls(profile, environment, "sign-out"));
5159
+ } catch {
5160
+ result.revocationFailed = grants.length;
5161
+ return result;
5162
+ }
5163
+ const outcomes = await Promise.all(grants.map(async (grant) => {
5164
+ try {
5165
+ if (!grant.clientId) return false;
5166
+ if (grant.refreshToken) {
5167
+ await oauth.revokeToken({ clientId: grant.clientId, token: grant.refreshToken, tokenTypeHint: "refresh_token" });
5168
+ }
5169
+ await oauth.revokeToken({ clientId: grant.clientId, token: grant.accessToken, tokenTypeHint: "access_token" });
5170
+ return true;
5171
+ } catch {
5172
+ return false;
5173
+ }
5174
+ }));
5175
+ result.revoked = outcomes.filter(Boolean).length;
5176
+ result.revocationFailed = outcomes.length - result.revoked;
5177
+ return result;
5050
5178
  }
5051
5179
  function credentialSourceFromConfig(config, environment = selectedEnvironment()) {
5052
5180
  const prefix = envVarPrefix(environment);
@@ -5069,8 +5197,8 @@ async function resolveCredential(environment = selectedEnvironment()) {
5069
5197
  }
5070
5198
  var REFRESH_SKEW_SECONDS = 60;
5071
5199
  var CONFIG_LOCK_RETRY_MS = 25;
5072
- var CONFIG_LOCK_DEFAULT_TIMEOUT_MS = 15e3;
5073
- var AUTH_EXCHANGE_TIMEOUT_MS = 1e4;
5200
+ var CONFIG_LOCK_DEFAULT_TIMEOUT_MS = 3e4;
5201
+ var AUTH_EXCHANGE_TIMEOUT_MS = 2e4;
5074
5202
  function configLockTimeoutMs() {
5075
5203
  const value = Number(process.env.TOPOLO_CONFIG_LOCK_TIMEOUT_MS);
5076
5204
  return Number.isFinite(value) && value >= 1e3 && value <= 12e4 ? value : CONFIG_LOCK_DEFAULT_TIMEOUT_MS;
@@ -5078,6 +5206,8 @@ function configLockTimeoutMs() {
5078
5206
  function boundedOAuth(serviceUrls) {
5079
5207
  const fetchImpl = (input2, init) => globalThis.fetch(input2, {
5080
5208
  ...init,
5209
+ // Never re-POST a refresh token, device code or revoked token to another origin.
5210
+ redirect: "error",
5081
5211
  signal: init?.signal ?? AbortSignal.timeout(AUTH_EXCHANGE_TIMEOUT_MS)
5082
5212
  });
5083
5213
  return new TopoloOAuth2({ serviceUrls, fetch: fetchImpl });
@@ -5126,6 +5256,61 @@ function readOwnerMarker(lockPath, name) {
5126
5256
  if (owner.version !== 1 || Object.keys(owner).sort().join(",") !== "identity,pid,version" || name !== "owner-" + owner.identity + ".json" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) throw new ConfigOwnershipError();
5127
5257
  return { bytes, pid: owner.pid };
5128
5258
  }
5259
+ function inspectConfigLockAt(path, ignoreOwnFence = false) {
5260
+ const lockPath = path + ".lock";
5261
+ let state = "UNKNOWN";
5262
+ let reason = "UNVERIFIED_CUSTODY";
5263
+ let witness = "unverified";
5264
+ try {
5265
+ const parent = lstatSync(dirname2(lockPath));
5266
+ if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== process.getuid?.() || parent.mode & 18) throw new ConfigOwnershipError();
5267
+ const parentIdentity = [parent.dev, parent.ino, parent.uid, parent.mode];
5268
+ if (!existsSync2(lockPath)) {
5269
+ try {
5270
+ lstatSync(lockPath);
5271
+ throw new ConfigOwnershipError();
5272
+ } catch (error) {
5273
+ if (error.code !== "ENOENT") throw error;
5274
+ }
5275
+ state = "FREE";
5276
+ reason = "ABSENT";
5277
+ witness = [parentIdentity, "absent"];
5278
+ } else {
5279
+ const directory = privateLockDirectory(lockPath);
5280
+ const entries = readdirSync(lockPath).sort();
5281
+ witness = [parentIdentity, lockIdentity(directory), entries];
5282
+ if (entries.length === 0) reason = "EMPTY_OWNER_UNKNOWN";
5283
+ else if (entries.length === 1 && OWNER_MARKER_NAME.test(entries[0])) {
5284
+ const { bytes, pid } = readOwnerMarker(lockPath, entries[0]);
5285
+ witness = [witness, createHash2("sha256").update(bytes).digest("hex")];
5286
+ try {
5287
+ process.kill(pid, 0);
5288
+ state = "LIVE";
5289
+ reason = "OWNER_PID_EXISTS";
5290
+ } catch {
5291
+ }
5292
+ }
5293
+ if (JSON.stringify(lockIdentity(lstatSync(lockPath))) !== JSON.stringify(lockIdentity(directory)) || JSON.stringify(readdirSync(lockPath).sort()) !== JSON.stringify(entries)) throw new ConfigOwnershipError();
5294
+ }
5295
+ if (!ignoreOwnFence && lockEntryExists(lockPath + ".reconcile")) {
5296
+ state = "UNKNOWN";
5297
+ reason = "RECONCILIATION_IN_PROGRESS";
5298
+ }
5299
+ } catch {
5300
+ state = "UNKNOWN";
5301
+ reason = "UNVERIFIED_CUSTODY";
5302
+ }
5303
+ return {
5304
+ configPath: path,
5305
+ lockPath,
5306
+ state,
5307
+ reason,
5308
+ generation: createHash2("sha256").update(JSON.stringify([path, witness])).digest("hex")
5309
+ };
5310
+ }
5311
+ async function inspectConfigLock() {
5312
+ return inspectConfigLockAt(resolve2(configPath()));
5313
+ }
5129
5314
  function writePrivateLockRecord(path, bytes) {
5130
5315
  const fd = openSync(path, "wx", 384);
5131
5316
  try {
@@ -5135,6 +5320,107 @@ function writePrivateLockRecord(path, bytes) {
5135
5320
  closeSync(fd);
5136
5321
  }
5137
5322
  }
5323
+ async function reconcileConfigLock(input2) {
5324
+ const path = resolve2(configPath());
5325
+ const before = inspectConfigLockAt(path);
5326
+ if (!input2 || typeof input2 !== "object") throw new ConfigOwnershipError();
5327
+ const inputFields = Object.getOwnPropertyDescriptors(input2);
5328
+ if (Reflect.ownKeys(inputFields).length !== 2 || typeof inputFields.expectedGeneration?.value !== "string" || !inputFields.review || !Object.prototype.hasOwnProperty.call(inputFields.review, "value")) throw new ConfigOwnershipError();
5329
+ const supplied = inputFields.review.value;
5330
+ if (!supplied || typeof supplied !== "object") throw new ConfigOwnershipError();
5331
+ const fields = Object.getOwnPropertyDescriptors(supplied);
5332
+ const names = ["kind", "configPath", "generation", "quiescenceEvidenceSha256", "reviewedBy", "expiresAt"];
5333
+ if (Reflect.ownKeys(fields).length !== names.length || names.some((name) => !fields[name] || !Object.prototype.hasOwnProperty.call(fields[name], "value") || typeof fields[name].value !== "string")) throw new ConfigOwnershipError();
5334
+ const expectedGeneration = inputFields.expectedGeneration.value;
5335
+ const review = Object.freeze({
5336
+ kind: fields.kind.value,
5337
+ configPath: fields.configPath.value,
5338
+ generation: fields.generation.value,
5339
+ quiescenceEvidenceSha256: fields.quiescenceEvidenceSha256.value,
5340
+ reviewedBy: fields.reviewedBy.value,
5341
+ expiresAt: fields.expiresAt.value
5342
+ });
5343
+ const expiry = Date.parse(review.expiresAt);
5344
+ if (review.kind !== "quiescent-empty-quarantine" || review.configPath !== path || review.generation !== expectedGeneration || before.generation !== expectedGeneration || !/^[a-f0-9]{64}$/.test(review.quiescenceEvidenceSha256) || typeof review.reviewedBy !== "string" || !/^[a-zA-Z0-9_.:-]{1,80}$/.test(review.reviewedBy) || !Number.isFinite(expiry) || expiry <= Date.now() || expiry > Date.now() + 15 * 6e4 || before.state !== "UNKNOWN" || before.reason !== "EMPTY_OWNER_UNKNOWN") throw new ConfigOwnershipError();
5345
+ const lockPath = before.lockPath;
5346
+ const fence = lockPath + ".reconcile";
5347
+ const identity = randomUUID2();
5348
+ const markerName = "owner-" + identity + ".json";
5349
+ const marker = JSON.stringify({ version: 1, identity, pid: process.pid });
5350
+ const quarantine = join3(dirname2(lockPath), ".credential-reconcile-" + identity);
5351
+ const captured = join3(quarantine, "lock");
5352
+ const receiptPath = join3(quarantine, "receipt.json");
5353
+ let capturedLock = false;
5354
+ let preserveFence = false;
5355
+ try {
5356
+ mkdirSync(fence, { mode: 448 });
5357
+ } catch {
5358
+ throw new ConfigOwnershipError();
5359
+ }
5360
+ const fenceDirectory = privateLockDirectory(fence);
5361
+ const fenceMarker = join3(fence, markerName);
5362
+ const assertFence = () => {
5363
+ const entry = privateLockDirectory(fence);
5364
+ if (entry.dev !== fenceDirectory.dev || entry.ino !== fenceDirectory.ino || readdirSync(fence).length !== 1 || readdirSync(fence)[0] !== markerName || readPrivateLockMarker(fenceMarker) !== marker) throw new ConfigOwnershipError();
5365
+ };
5366
+ try {
5367
+ writePrivateLockRecord(fenceMarker, marker);
5368
+ assertFence();
5369
+ const current = inspectConfigLockAt(path, true);
5370
+ if (current.state !== "UNKNOWN" || current.reason !== "EMPTY_OWNER_UNKNOWN" || current.generation !== before.generation || Date.now() >= expiry) throw new ConfigOwnershipError();
5371
+ const directory = privateLockDirectory(lockPath);
5372
+ mkdirSync(quarantine, { mode: 448 });
5373
+ writePrivateLockRecord(receiptPath, JSON.stringify({
5374
+ schemaVersion: 1,
5375
+ disposition: "PENDING_CAPTURE",
5376
+ classificationBefore: "UNKNOWN",
5377
+ before,
5378
+ review,
5379
+ directoryIdentity: [directory.dev, directory.ino, directory.uid, directory.mode],
5380
+ quiescence: "caller-reviewed; legacy and bare writers not globally enforced by SDK",
5381
+ configCredentialOrOAuthEffectsByThisAPI: "none"
5382
+ }, null, 2));
5383
+ assertFence();
5384
+ if (inspectConfigLockAt(path, true).generation !== before.generation || Date.now() >= expiry) throw new ConfigOwnershipError();
5385
+ renameSync(lockPath, captured);
5386
+ capturedLock = true;
5387
+ const entry = privateLockDirectory(captured);
5388
+ if (entry.dev !== directory.dev || entry.ino !== directory.ino || readdirSync(captured).length || lockEntryExists(lockPath) || Date.now() >= expiry) throw new ConfigOwnershipError();
5389
+ assertFence();
5390
+ writePrivateLockRecord(join3(quarantine, "outcome.json"), JSON.stringify({
5391
+ disposition: "CAPTURE_VERIFIED_PENDING_FENCE_CLOSURE",
5392
+ classificationBefore: "UNKNOWN",
5393
+ fenceClosureVerified: false,
5394
+ noDeathOrPriorRemoteEffectResolutionInferred: true
5395
+ }));
5396
+ } catch (error) {
5397
+ if (!capturedLock) throw error instanceof ConfigOwnershipError ? error : new ConfigOwnershipError();
5398
+ preserveFence = true;
5399
+ return { outcome: "UNRESOLVED_CAPTURE", before, quarantinePath: quarantine, receiptPath, fenceRetained: true };
5400
+ } finally {
5401
+ if (!preserveFence) {
5402
+ try {
5403
+ assertFence();
5404
+ const capturedMarker = join3(quarantine, markerName);
5405
+ if (!lockEntryExists(quarantine)) mkdirSync(quarantine, { mode: 448 });
5406
+ renameSync(fenceMarker, capturedMarker);
5407
+ const entry = privateLockDirectory(fence);
5408
+ if (entry.dev !== fenceDirectory.dev || entry.ino !== fenceDirectory.ino || readdirSync(fence).length || readPrivateLockMarker(capturedMarker) !== marker) {
5409
+ try {
5410
+ linkSync(capturedMarker, fenceMarker);
5411
+ } catch {
5412
+ }
5413
+ throw new ConfigOwnershipError();
5414
+ }
5415
+ rmdirSync(fence);
5416
+ } catch {
5417
+ if (capturedLock) return { outcome: "UNRESOLVED_CAPTURE", before, quarantinePath: quarantine, receiptPath, fenceRetained: true };
5418
+ throw new ConfigOwnershipError();
5419
+ }
5420
+ }
5421
+ }
5422
+ return { outcome: "QUARANTINED_UNKNOWN_EMPTY", before, quarantinePath: quarantine, receiptPath, fenceRetained: false };
5423
+ }
5138
5424
  function deadOwnerMarker(lockPath, ignoreOwnFence = false) {
5139
5425
  try {
5140
5426
  if (!ignoreOwnFence && lockEntryExists(lockPath + ".reconcile")) return null;
@@ -5209,6 +5495,12 @@ function reclaimDeadOwnerLock(lockPath, markerName, marker) {
5209
5495
  return reclaimed;
5210
5496
  }
5211
5497
  var RELEASE_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
5498
+ function unreadableConfig(path) {
5499
+ return new TopoloSdkError2(
5500
+ "config_unreadable",
5501
+ `The Topolo configuration at ${path} is not a valid JSON object, so it was left unchanged. Repair or move it, then retry.`
5502
+ );
5503
+ }
5212
5504
  var PERSIST_ATTEMPTS = 3;
5213
5505
  async function withConfigWriteLock(operation) {
5214
5506
  const lockPath = `${configPath()}.lock`;
@@ -5291,11 +5583,14 @@ async function withConfigWriteLock(operation) {
5291
5583
  const before = snapshot();
5292
5584
  let current = {};
5293
5585
  if (before !== null) {
5586
+ let parsed = {};
5294
5587
  try {
5295
- current = JSON.parse(before);
5296
- } catch (error) {
5297
- if (!options.allowCreate) throw error;
5588
+ if (before.trim()) parsed = JSON.parse(before);
5589
+ } catch {
5590
+ throw unreadableConfig(path);
5298
5591
  }
5592
+ if (!isConfigObject(parsed)) throw unreadableConfig(path);
5593
+ current = parsed;
5299
5594
  }
5300
5595
  const updated = update(current);
5301
5596
  const fd = openSync(temporary, "wx", 384);
@@ -5377,7 +5672,8 @@ async function withConfigWriteLock(operation) {
5377
5672
  function renewalFailure(error) {
5378
5673
  const path = "/api/developer-oauth/token";
5379
5674
  if (error instanceof ConfigOwnershipError) return new ConfigOwnershipError();
5380
- if (error instanceof TopoloReauthRequiredError) return error;
5675
+ if (error instanceof TopoloReauthRequiredError2) return error;
5676
+ if (error instanceof TopoloSdkError2 && error.code === "config_unreadable") return error;
5381
5677
  if ((error instanceof TopoloAuthError2 || error instanceof TopoloHttpError2) && error.status === 429) {
5382
5678
  const message3 = "Topolo sign-in renewal is temporarily rate limited by Auth. Retry after the Retry-After interval; the saved sign-in has been retained.";
5383
5679
  return new TopoloRateLimitError2(
@@ -5453,6 +5749,35 @@ function singleFlight(key, run) {
5453
5749
  renewalsInFlight.set(key, started);
5454
5750
  return started;
5455
5751
  }
5752
+ var GATEWAY_STATUSES = /* @__PURE__ */ new Set([502, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]);
5753
+ var NOT_SENT_CODES = /* @__PURE__ */ new Set([
5754
+ "ENOTFOUND",
5755
+ "EAI_AGAIN",
5756
+ "EAI_NONAME",
5757
+ "ECONNREFUSED",
5758
+ "ENETUNREACH",
5759
+ "EHOSTUNREACH",
5760
+ "EADDRNOTAVAIL",
5761
+ "UND_ERR_CONNECT_TIMEOUT",
5762
+ "CERT_HAS_EXPIRED",
5763
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
5764
+ "SELF_SIGNED_CERT_IN_CHAIN",
5765
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
5766
+ "ERR_TLS_CERT_ALTNAME_INVALID"
5767
+ ]);
5768
+ function answeredWithoutRotation(error) {
5769
+ if (error instanceof TopoloAuthError2 || error instanceof TopoloHttpError2) return !GATEWAY_STATUSES.has(Number(error.status));
5770
+ for (let cause = error, depth = 0; cause && depth < 4; cause = cause.cause, depth += 1) {
5771
+ const code = cause.code;
5772
+ if (typeof code === "string" && NOT_SENT_CODES.has(code)) return true;
5773
+ }
5774
+ return false;
5775
+ }
5776
+ function usableTokenResponse(tokens) {
5777
+ if (!tokens || typeof tokens !== "object") return false;
5778
+ const value = tokens;
5779
+ return typeof value.access_token === "string" && value.access_token.length > 0 && typeof value.expires_in === "number" && Number.isFinite(value.expires_in) && (value.refresh_token === void 0 || typeof value.refresh_token === "string" && value.refresh_token.length > 0);
5780
+ }
5456
5781
  async function renewTarget(environment, target, options) {
5457
5782
  const observedProfile = profileForEnvironment(await readConfig(), environment);
5458
5783
  const observedTarget = resolveTarget(observedProfile, target);
@@ -5474,6 +5799,7 @@ async function renewTarget(environment, target, options) {
5474
5799
  if (options.force && observed.kind === "access_token" && observed.accessToken !== credential.accessToken && credential.expiresAt !== void 0 && credential.expiresAt > now) return { credential };
5475
5800
  if (!options.force && credential.expiresAt && credential.expiresAt - REFRESH_SKEW_SECONDS > now) return { credential };
5476
5801
  const sent = credential.refreshToken;
5802
+ const subject = accessTokenSubject(credential.accessToken) ?? resolved.context?.userId;
5477
5803
  const endGrant = async (reason) => {
5478
5804
  await persist((current) => {
5479
5805
  const next = clone(current);
@@ -5481,35 +5807,67 @@ async function renewTarget(environment, target, options) {
5481
5807
  touched.add(resolved.key);
5482
5808
  retire(next, sent, reason);
5483
5809
  for (const key of touched) {
5484
- setReauthorization(next, environment, key, (existing) => ({ ...existing, reason, since: existing?.since ?? nowSeconds() }));
5810
+ setReauthorization(next, environment, key, (existing) => ({
5811
+ ...subject ? { expectedSubject: subject } : {},
5812
+ ...existing,
5813
+ reason,
5814
+ since: existing?.since ?? nowSeconds()
5815
+ }));
5485
5816
  }
5486
5817
  return next;
5487
5818
  });
5488
5819
  return { reauthorize: resolved.key };
5489
5820
  };
5821
+ const outcomeUnknown = async () => {
5822
+ if (!options.force && credential.expiresAt && credential.expiresAt > nowSeconds()) return { credential };
5823
+ return endGrant("rotated_grant_lost");
5824
+ };
5490
5825
  const retired = retiredEntry(config, sent);
5491
5826
  if (retired) {
5492
5827
  return endGrant(retired.reason === "rotated" ? "rotated_grant_lost" : retired.reason);
5493
5828
  }
5494
5829
  const serviceUrls = authServiceUrls(profile, environment, target.kind === "active" ? "credential refresh" : "context refresh");
5830
+ const oauth = boundedOAuth(serviceUrls);
5831
+ try {
5832
+ await persist((current) => {
5833
+ const next = clone(current);
5834
+ retire(next, sent, "rotated", true);
5835
+ return next;
5836
+ });
5837
+ } catch (error) {
5838
+ throw error instanceof TopoloSdkError2 ? renewalFailure(error) : new TopoloSdkError2("config_write_failed", "The Topolo configuration could not be updated, so the sign-in was not renewed. Nothing was sent to Auth; the saved sign-in has been retained.");
5839
+ }
5495
5840
  let tokens;
5496
5841
  try {
5497
- tokens = await boundedOAuth(serviceUrls).refreshToken({ clientId: resolved.oauthClientId, refreshToken: sent });
5842
+ tokens = await oauth.refreshToken({ clientId: resolved.oauthClientId, refreshToken: sent });
5498
5843
  } catch (error) {
5499
5844
  if (isTerminalRefusal(error)) return endGrant(error.code);
5845
+ if (!answeredWithoutRotation(error)) return outcomeUnknown();
5846
+ try {
5847
+ await persist((current) => {
5848
+ const next = clone(current);
5849
+ clearInFlight(next, sent);
5850
+ return next;
5851
+ });
5852
+ } catch (persistError) {
5853
+ throw renewalFailure(persistError);
5854
+ }
5500
5855
  throw renewalFailure(error);
5501
5856
  }
5857
+ if (!usableTokenResponse(tokens)) return outcomeUnknown();
5858
+ const issued = tokens;
5502
5859
  const refreshed = {
5503
5860
  kind: "access_token",
5504
- accessToken: tokens.access_token,
5505
- refreshToken: tokens.refresh_token ?? sent,
5506
- expiresAt: nowSeconds() + tokens.expires_in
5861
+ accessToken: issued.access_token,
5862
+ refreshToken: issued.refresh_token ?? sent,
5863
+ expiresAt: nowSeconds() + issued.expires_in
5507
5864
  };
5508
5865
  try {
5509
5866
  await persist((current) => {
5510
5867
  const next = clone(current);
5511
5868
  replaceGrant(next, environment, sent, refreshed);
5512
- if (tokens.refresh_token && tokens.refresh_token !== sent) retire(next, sent, "rotated");
5869
+ if (issued.refresh_token && issued.refresh_token !== sent) retire(next, sent, "rotated");
5870
+ else clearInFlight(next, sent);
5513
5871
  return next;
5514
5872
  });
5515
5873
  } catch (error) {
@@ -5529,17 +5887,30 @@ async function refreshCredentialIfNeeded(environment = selectedEnvironment(), op
5529
5887
  async function refreshContextCredentialIfNeeded(environment, key, options = {}) {
5530
5888
  return renewTarget(environment, { kind: "context", key }, options);
5531
5889
  }
5532
- function decodeTokenOrganization(accessToken) {
5890
+ function accessTokenClaims(accessToken) {
5533
5891
  try {
5534
- const payload = accessToken.split(".")[1];
5535
- if (!payload) return { known: false, organizationId: null };
5892
+ const payload = accessToken?.split(".")[1];
5893
+ if (!payload) return null;
5536
5894
  const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
5537
- const org = claims.orgId ?? claims.organizationId ?? claims.org_id ?? null;
5538
- return { known: true, organizationId: typeof org === "string" && org ? org : null };
5895
+ return claims && typeof claims === "object" && !Array.isArray(claims) ? claims : null;
5539
5896
  } catch {
5540
- return { known: false, organizationId: null };
5897
+ return null;
5541
5898
  }
5542
5899
  }
5900
+ function accessTokenSubject(accessToken) {
5901
+ const sub = accessTokenClaims(accessToken)?.sub;
5902
+ return typeof sub === "string" && sub ? sub : void 0;
5903
+ }
5904
+ function accessTokenExpiry(accessToken) {
5905
+ const exp = accessTokenClaims(accessToken)?.exp;
5906
+ return typeof exp === "number" && Number.isSafeInteger(exp) && exp > 0 ? exp : void 0;
5907
+ }
5908
+ function decodeTokenOrganization(accessToken) {
5909
+ const claims = accessTokenClaims(accessToken);
5910
+ if (!claims) return { known: false, organizationId: null };
5911
+ const org = claims.orgId ?? claims.organizationId ?? claims.org_id ?? null;
5912
+ return { known: true, organizationId: typeof org === "string" && org ? org : null };
5913
+ }
5543
5914
  async function continueReauthorization(environment, key) {
5544
5915
  return withConfigWriteLock(async (_assertOwned, persist) => {
5545
5916
  const config = await readConfig();
@@ -5596,7 +5967,21 @@ async function continueReauthorization(environment, key) {
5596
5967
  };
5597
5968
  const organization = decodeTokenOrganization(tokens.access_token);
5598
5969
  const approvedKey = organization.known ? organization.organizationId ? `org:${organization.organizationId}` : "personal" : null;
5970
+ const approvedSubject = accessTokenSubject(tokens.access_token);
5599
5971
  const matches = key === DEFAULT_SLOT || approvedKey === key;
5972
+ const destination = matches ? key : approvedKey;
5973
+ const destinationUsers = destination ? slotUsers(profile, destination) : /* @__PURE__ */ new Set();
5974
+ if (state.expectedSubject) destinationUsers.add(state.expectedSubject);
5975
+ if ([...destinationUsers].some((user) => user !== approvedSubject)) {
5976
+ const updated = {
5977
+ ...state,
5978
+ mismatch: { approvedContext: "a different user", at: nowSeconds(), differentUser: true }
5979
+ };
5980
+ delete updated.pending;
5981
+ await save(() => updated);
5982
+ throw reauthRequired(environment, key, updated, "user_mismatch");
5983
+ }
5984
+ const signedInAs = approvedSubject ? { userId: approvedSubject } : {};
5600
5985
  await persist((current) => {
5601
5986
  const next = clone(current);
5602
5987
  const nextProfile = (next.profiles ??= {})[environment] ??= {};
@@ -5610,7 +5995,8 @@ async function continueReauthorization(environment, key) {
5610
5995
  nextProfile.contexts = { ...nextProfile.contexts ?? {}, [key]: {
5611
5996
  ...existing ?? (key.startsWith("org:") ? { contextType: "organization", organizationId: key.slice(4) } : { contextType: "personal" }),
5612
5997
  credential: approved,
5613
- oauthClientId: clientId
5998
+ oauthClientId: clientId,
5999
+ ...signedInAs
5614
6000
  } };
5615
6001
  if (nextProfile.activeContext === key) nextProfile.credential = approved;
5616
6002
  }
@@ -5621,7 +6007,8 @@ async function continueReauthorization(environment, key) {
5621
6007
  nextProfile.contexts = { ...nextProfile.contexts ?? {}, [approvedKey]: {
5622
6008
  ...existing ?? (approvedKey.startsWith("org:") ? { contextType: "organization", organizationId: approvedKey.slice(4) } : { contextType: "personal" }),
5623
6009
  credential: approved,
5624
- oauthClientId: clientId
6010
+ oauthClientId: clientId,
6011
+ ...signedInAs
5625
6012
  } };
5626
6013
  if (nextProfile.activeContext === approvedKey) nextProfile.credential = approved;
5627
6014
  }
@@ -5645,7 +6032,7 @@ async function continueReauthorization(environment, key) {
5645
6032
  throw reauthRequired(environment, key, state, pending.denied ? "denied" : "awaiting_approval");
5646
6033
  }
5647
6034
  if (state.startRetryAt && state.startRetryAt > now) {
5648
- throw reauthRequired(environment, key, state, state.mismatch ? "organization_mismatch" : "unavailable");
6035
+ throw reauthRequired(environment, key, state, mismatchState(state));
5649
6036
  }
5650
6037
  try {
5651
6038
  const started = await oauth.requestDeviceCode({ clientId });
@@ -5666,7 +6053,7 @@ async function continueReauthorization(environment, key) {
5666
6053
  await save(() => state);
5667
6054
  throw reauthRequired(environment, key, state, "awaiting_approval");
5668
6055
  } catch (error) {
5669
- if (error instanceof TopoloReauthRequiredError || error instanceof ConfigOwnershipError) throw error;
6056
+ if (error instanceof TopoloReauthRequiredError2 || error instanceof ConfigOwnershipError) throw error;
5670
6057
  const retryAfter = Number(error.retryAfter);
5671
6058
  state = { ...state, startRetryAt: now + Math.max(30, Number.isFinite(retryAfter) ? retryAfter : 0) };
5672
6059
  delete state.pending;
@@ -5684,7 +6071,7 @@ async function awaitReauthorization(environment, key, options = {}) {
5684
6071
  try {
5685
6072
  return await continueReauthorization(environment, slotKey);
5686
6073
  } catch (error) {
5687
- if (!(error instanceof TopoloReauthRequiredError)) throw error;
6074
+ if (!(error instanceof TopoloReauthRequiredError2)) throw error;
5688
6075
  const prompt = error.reauthorization;
5689
6076
  if (prompt.userCode !== lastCode) {
5690
6077
  lastCode = prompt.userCode;
@@ -5732,110 +6119,6 @@ function resolvedProfileConfig(config, environment = selectedEnvironment()) {
5732
6119
  };
5733
6120
  }
5734
6121
 
5735
- // src/config.ts
5736
- async function authenticatedEnvironments(configOrPromise = readConfig()) {
5737
- const config = await configOrPromise;
5738
- const found = /* @__PURE__ */ new Set();
5739
- if (config.credential) found.add("production");
5740
- for (const environment of TOPOLO_ENVIRONMENTS) {
5741
- const profile = config.profiles?.[environment];
5742
- if (!profile) continue;
5743
- if (profile.credential || Object.keys(profile.contexts ?? {}).length > 0) {
5744
- found.add(environment);
5745
- }
5746
- }
5747
- return TOPOLO_ENVIRONMENTS.filter((environment) => found.has(environment));
5748
- }
5749
- function environmentLabel(environment = selectedEnvironment()) {
5750
- return environment;
5751
- }
5752
- function developerConsoleUrl(environment = selectedEnvironment()) {
5753
- if (environment === "production") return "https://developers.topolo.app";
5754
- if (environment === "development") return "https://developers.topolo.dev";
5755
- return "https://developers.stg.topolo.us";
5756
- }
5757
- function oauthClientRegistrationUrl(environment = selectedEnvironment()) {
5758
- return `${developerConsoleUrl(environment)}/developers/oauth-clients`;
5759
- }
5760
- function apiKeyIssuanceUrl(environment = selectedEnvironment()) {
5761
- return `${developerConsoleUrl(environment)}/credentials`;
5762
- }
5763
- var DEFAULT_CLI_CLIENT_IDS = {
5764
- production: "topolo-cli",
5765
- staging: "topolo-cli",
5766
- development: "topolo-cli"
5767
- };
5768
- function resolveOAuthClientId(config, environment = selectedEnvironment()) {
5769
- const envSpecific = process.env[`${envVarPrefix(environment)}_CLI_CLIENT_ID`];
5770
- if (envSpecific) return envSpecific;
5771
- if (process.env.TOPOLO_CLI_CLIENT_ID) return process.env.TOPOLO_CLI_CLIENT_ID;
5772
- return DEFAULT_CLI_CLIENT_IDS[environment] ?? profileForEnvironment(config, environment).oauthClientId;
5773
- }
5774
- function signedInContextKeys(config, environment) {
5775
- return Object.keys(profileForEnvironment(config, environment).contexts ?? {});
5776
- }
5777
- function matchContextSelector(config, environment, selector) {
5778
- const normalized = selector.trim().toLowerCase();
5779
- if (!normalized || /[\r\n]/.test(selector)) {
5780
- throw new Error("Context must be a non-empty single-line identifier.");
5781
- }
5782
- const contexts = Object.entries(profileForEnvironment(config, environment).contexts ?? {});
5783
- const exact = contexts.find(([key]) => key.toLowerCase() === normalized);
5784
- if (exact) return exact[0];
5785
- const matches = contexts.filter(([, context]) => [
5786
- context.organizationId,
5787
- context.organizationSlug,
5788
- context.organizationName
5789
- ].some((value) => String(value || "").trim().toLowerCase() === normalized));
5790
- if (matches.length > 1) {
5791
- throw new Error(`Context "${selector.trim()}" is ambiguous in ${environment}; use an exact key from \`topolo --env ${environment} context ls\`.`);
5792
- }
5793
- return matches[0]?.[0] ?? null;
5794
- }
5795
- function commandContextKey(config, environment, explicit = process.env.TOPOLO_CONTEXT) {
5796
- const selector = explicit?.trim();
5797
- if (selector) return matchContextSelector(config, environment, selector) ?? selector;
5798
- return implicitContextKey(config, environment) ?? void 0;
5799
- }
5800
- function implicitContextKey(config, environment) {
5801
- if (credentialSourceFromConfig(config, environment).startsWith("env:")) return null;
5802
- const keys = signedInContextKeys(config, environment);
5803
- if (keys.length > 1) throw new ContextNotSafeToAssumeError(environment, keys);
5804
- return keys[0] ?? null;
5805
- }
5806
- var ContextNotSafeToAssumeError = class extends Error {
5807
- constructor(environment, contexts) {
5808
- super(
5809
- `This machine is signed in to ${contexts.length} Topolo contexts in ${environment} (${contexts.join(", ")}), so the context is no longer safe to assume.
5810
-
5811
- State it explicitly:
5812
- topolo --env ${environment} --context <${contexts.join("|")}> ...
5813
-
5814
- Or set it once for the whole session:
5815
- export TOPOLO_CONTEXT=<${contexts.join("|")}>`
5816
- );
5817
- this.environment = environment;
5818
- this.contexts = contexts;
5819
- this.name = "ContextNotSafeToAssumeError";
5820
- }
5821
- environment;
5822
- contexts;
5823
- };
5824
- async function withPinnedContext(options, read = () => readConfig()) {
5825
- if (options.context?.trim() || options.installationId?.trim()) return options;
5826
- const environment = normalizeEnvironment(options.environment?.trim() || process.env.TOPOLO_ENV || "production");
5827
- const keys = signedInContextKeys(await read(), environment);
5828
- if (keys.length === 1) return { ...options, context: keys[0] };
5829
- if (!keys.length) {
5830
- throw new Error(
5831
- `No Topolo context is signed in for ${environment}, so there is nothing to pin. Run \`topolo --env ${environment} auth login\` first, or pass --context <context>.`
5832
- );
5833
- }
5834
- throw new Error(
5835
- `${keys.length} Topolo contexts are signed in for ${environment} (${keys.join(", ")}). Pass --context <context> to choose the one this MCP registration pins.`
5836
- );
5837
- }
5838
-
5839
6122
  // src/output.ts
5840
6123
  function resolveOutputMode(override) {
5841
6124
  if (override === true) return "json";
@@ -5895,6 +6178,7 @@ function normalizeError(err) {
5895
6178
  };
5896
6179
  const body = err.body;
5897
6180
  const status = err.status;
6181
+ const localLockConflict = code === "state_conflict" && status === void 0 && body === void 0 && err.service === void 0;
5898
6182
  const suppliedDetails = err.details;
5899
6183
  return {
5900
6184
  message: status !== void 0 || body !== void 0 ? publicTopoloErrorMessage({ code, body, status, message: err.message }) : err.message,
@@ -5907,7 +6191,7 @@ function normalizeError(err) {
5907
6191
  // SDK HTTP errors already carry sanitized details; projecting them again
5908
6192
  // would drop form-level issues that have no field path.
5909
6193
  details: reauthorizationDetails(err) ?? (err instanceof TopoloHttpError || err instanceof TopoloAuthError || err instanceof TopoloPermissionError ? err.details : extractTopoloServiceErrorDetails(suppliedDetails === void 0 ? body : { details: suppliedDetails })),
5910
- hint: reauthorizationHint(err) ?? topoloErrorHint({ code, body }) ?? (code ? fallbackHintMap[code] : void 0),
6194
+ hint: reauthorizationHint(err) ?? topoloErrorHint({ code, body }) ?? (localLockConflict ? "The saved sign-in is locked by another or an interrupted Topolo process. Run `topolo auth lock` to see who holds it and how to recover." : code ? fallbackHintMap[code] : void 0),
5911
6195
  exitCode: status === 401 || code === "auth_error" || code === "confirmation_required" ? 2 : status === 403 || code === "permission_denied" ? 3 : code === "immutable_drift" ? 4 : 1
5912
6196
  };
5913
6197
  }
@@ -5968,9 +6252,214 @@ function readCliVersion() {
5968
6252
  }
5969
6253
  var CLI_VERSION = readCliVersion();
5970
6254
 
6255
+ // src/commands/auth-lock.ts
6256
+ var QUIESCENCE_WAIT_MS = 2e3;
6257
+ var REVIEW_TTL_MS = 5 * 6e4;
6258
+ function lockGuidance(diagnostic) {
6259
+ const { state, reason, lockPath, generation } = diagnostic;
6260
+ if (state === "FREE") return "No lock is held. Sign-in renewal is not blocked.";
6261
+ if (state === "LIVE") {
6262
+ return `A running process holds the lock; a renewal normally finishes within 10 seconds. If it stays held after every Topolo CLI and MCP process has exited (after a restart the recorded process number can belong to another program), quit them all and move ${lockPath} aside.`;
6263
+ }
6264
+ if (reason === "EMPTY_OWNER_UNKNOWN") {
6265
+ return `An empty lock was left by an interrupted writer. Close other Topolo CLI and MCP processes, then run:
6266
+ topolo auth lock --reconcile ${generation}`;
6267
+ }
6268
+ if (reason === "RECONCILIATION_IN_PROGRESS") {
6269
+ return `A lock recovery is running or was interrupted (${lockPath}.reconcile). Wait for it, or if no Topolo process is running, keep the fence for review and move both paths aside.`;
6270
+ }
6271
+ return `The lock's owner cannot be verified on this system (unexpected owner, permissions or platform). If no Topolo CLI or MCP process is running, move ${lockPath} aside.`;
6272
+ }
6273
+ function registerAuthLockCommand(auth) {
6274
+ auth.command("lock").description("Inspect, and recover, the lock that serializes sign-in renewal").option("--reconcile <generation>", "Quarantine an empty lock left by an interrupted writer (generation from `topolo auth lock`)").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
6275
+ const mode = resolveOutputMode(opts.json);
6276
+ try {
6277
+ const diagnostic = await inspectConfigLock();
6278
+ if (opts.reconcile === void 0) {
6279
+ emit(
6280
+ { lock: diagnostic, guidance: lockGuidance(diagnostic) },
6281
+ mode,
6282
+ () => [
6283
+ `Lock: ${diagnostic.lockPath}`,
6284
+ `State: ${diagnostic.state} (${diagnostic.reason})`,
6285
+ `Generation: ${diagnostic.generation}`,
6286
+ "",
6287
+ lockGuidance(diagnostic)
6288
+ ].join("\n")
6289
+ );
6290
+ return;
6291
+ }
6292
+ const generation = opts.reconcile.trim();
6293
+ if (diagnostic.state !== "UNKNOWN" || diagnostic.reason !== "EMPTY_OWNER_UNKNOWN") {
6294
+ throw new Error(`Only an empty lock left by an interrupted writer can be reconciled; this lock is ${diagnostic.state} (${diagnostic.reason}). ${lockGuidance(diagnostic)}`);
6295
+ }
6296
+ if (diagnostic.generation !== generation) {
6297
+ throw new Error("The lock changed since it was inspected. Run `topolo auth lock` again and use the generation it prints.");
6298
+ }
6299
+ await new Promise((resolve6) => setTimeout(resolve6, QUIESCENCE_WAIT_MS));
6300
+ const settled = await inspectConfigLock();
6301
+ if (settled.generation !== generation) {
6302
+ throw new Error("The lock changed while waiting, so another process is using it. Nothing was changed.");
6303
+ }
6304
+ const evidence = JSON.stringify({
6305
+ observer: `topolo-cli/${CLI_VERSION}`,
6306
+ before: diagnostic,
6307
+ after: settled,
6308
+ waitedMs: QUIESCENCE_WAIT_MS
6309
+ });
6310
+ const result = await reconcileConfigLock({
6311
+ expectedGeneration: generation,
6312
+ review: {
6313
+ kind: "quiescent-empty-quarantine",
6314
+ configPath: diagnostic.configPath,
6315
+ generation,
6316
+ quiescenceEvidenceSha256: createHash3("sha256").update(evidence).digest("hex"),
6317
+ reviewedBy: "topolo-cli:auth-lock",
6318
+ expiresAt: new Date(Date.now() + REVIEW_TTL_MS).toISOString()
6319
+ }
6320
+ });
6321
+ const recovered = result.outcome === "QUARANTINED_UNKNOWN_EMPTY";
6322
+ if (!recovered) process.exitCode = 1;
6323
+ emit(
6324
+ { ok: recovered, outcome: result.outcome, quarantinePath: result.quarantinePath, fenceRetained: result.fenceRetained },
6325
+ mode,
6326
+ () => recovered ? `Recovered: the empty lock was moved to ${result.quarantinePath}. Sign-in renewal can proceed.` : `Not recovered: the lock changed during capture. Evidence and the recovery fence were kept in ${result.quarantinePath} for review.`
6327
+ );
6328
+ } catch (error) {
6329
+ fail2(error, mode);
6330
+ }
6331
+ });
6332
+ }
6333
+
6334
+ // src/lib/open-browser.ts
6335
+ import { spawn } from "child_process";
6336
+ import { platform as platform2 } from "os";
6337
+ function tryOpenBrowser(url) {
6338
+ try {
6339
+ const os = platform2();
6340
+ const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
6341
+ const args = os === "win32" ? ["", url] : [url];
6342
+ const child = spawn(cmd, args, {
6343
+ detached: true,
6344
+ stdio: "ignore",
6345
+ ...os === "win32" ? { shell: true } : {}
6346
+ });
6347
+ child.unref();
6348
+ return true;
6349
+ } catch {
6350
+ return false;
6351
+ }
6352
+ }
6353
+
6354
+ // src/config.ts
6355
+ async function authenticatedEnvironments(configOrPromise = readConfig()) {
6356
+ const config = await configOrPromise;
6357
+ const found = /* @__PURE__ */ new Set();
6358
+ if (config.credential) found.add("production");
6359
+ for (const environment of TOPOLO_ENVIRONMENTS) {
6360
+ const profile = config.profiles?.[environment];
6361
+ if (!profile) continue;
6362
+ if (profile.credential || Object.keys(profile.contexts ?? {}).length > 0) {
6363
+ found.add(environment);
6364
+ }
6365
+ }
6366
+ return TOPOLO_ENVIRONMENTS.filter((environment) => found.has(environment));
6367
+ }
6368
+ function environmentLabel(environment = selectedEnvironment()) {
6369
+ return environment;
6370
+ }
6371
+ function developerConsoleUrl(environment = selectedEnvironment()) {
6372
+ if (environment === "production") return "https://developers.topolo.app";
6373
+ if (environment === "development") return "https://developers.topolo.dev";
6374
+ return "https://developers.stg.topolo.us";
6375
+ }
6376
+ function oauthClientRegistrationUrl(environment = selectedEnvironment()) {
6377
+ return `${developerConsoleUrl(environment)}/developers/oauth-clients`;
6378
+ }
6379
+ function apiKeyIssuanceUrl(environment = selectedEnvironment()) {
6380
+ return `${developerConsoleUrl(environment)}/credentials`;
6381
+ }
6382
+ var DEFAULT_CLI_CLIENT_IDS = {
6383
+ production: "topolo-cli",
6384
+ staging: "topolo-cli",
6385
+ development: "topolo-cli"
6386
+ };
6387
+ function resolveOAuthClientId(config, environment = selectedEnvironment()) {
6388
+ const envSpecific = process.env[`${envVarPrefix(environment)}_CLI_CLIENT_ID`];
6389
+ if (envSpecific) return envSpecific;
6390
+ if (process.env.TOPOLO_CLI_CLIENT_ID) return process.env.TOPOLO_CLI_CLIENT_ID;
6391
+ return DEFAULT_CLI_CLIENT_IDS[environment] ?? profileForEnvironment(config, environment).oauthClientId;
6392
+ }
6393
+ function signedInContextKeys(config, environment) {
6394
+ return Object.keys(profileForEnvironment(config, environment).contexts ?? {});
6395
+ }
6396
+ function matchContextSelector(config, environment, selector) {
6397
+ const normalized = selector.trim().toLowerCase();
6398
+ if (!normalized || /[\r\n]/.test(selector)) {
6399
+ throw new Error("Context must be a non-empty single-line identifier.");
6400
+ }
6401
+ const contexts = Object.entries(profileForEnvironment(config, environment).contexts ?? {});
6402
+ const exact = contexts.find(([key]) => key.toLowerCase() === normalized);
6403
+ if (exact) return exact[0];
6404
+ const matches = contexts.filter(([, context]) => [
6405
+ context.organizationId,
6406
+ context.organizationSlug,
6407
+ context.organizationName
6408
+ ].some((value) => String(value || "").trim().toLowerCase() === normalized));
6409
+ if (matches.length > 1) {
6410
+ throw new Error(`Context "${selector.trim()}" is ambiguous in ${environment}; use an exact key from \`topolo --env ${environment} context ls\`.`);
6411
+ }
6412
+ return matches[0]?.[0] ?? null;
6413
+ }
6414
+ function commandContextKey(config, environment, explicit = process.env.TOPOLO_CONTEXT) {
6415
+ const selector = explicit?.trim();
6416
+ if (selector) return matchContextSelector(config, environment, selector) ?? selector;
6417
+ return implicitContextKey(config, environment) ?? void 0;
6418
+ }
6419
+ function implicitContextKey(config, environment) {
6420
+ if (credentialSourceFromConfig(config, environment).startsWith("env:")) return null;
6421
+ if (profileForEnvironment(config, environment).credential?.kind === "api_key") return null;
6422
+ const keys = signedInContextKeys(config, environment);
6423
+ if (keys.length > 1) throw new ContextNotSafeToAssumeError(environment, keys);
6424
+ return keys[0] ?? null;
6425
+ }
6426
+ var ContextNotSafeToAssumeError = class extends Error {
6427
+ constructor(environment, contexts) {
6428
+ super(
6429
+ `This machine is signed in to ${contexts.length} Topolo contexts in ${environment} (${contexts.join(", ")}), so the context is no longer safe to assume.
6430
+
6431
+ State it explicitly:
6432
+ topolo --env ${environment} --context <${contexts.join("|")}> ...
6433
+
6434
+ Or set it once for the whole session:
6435
+ export TOPOLO_CONTEXT=<${contexts.join("|")}>`
6436
+ );
6437
+ this.environment = environment;
6438
+ this.contexts = contexts;
6439
+ this.name = "ContextNotSafeToAssumeError";
6440
+ }
6441
+ environment;
6442
+ contexts;
6443
+ };
6444
+ async function withPinnedContext(options, read = () => readConfig()) {
6445
+ if (options.context?.trim() || options.installationId?.trim()) return options;
6446
+ const environment = normalizeEnvironment(options.environment?.trim() || process.env.TOPOLO_ENV || "production");
6447
+ const keys = signedInContextKeys(await read(), environment);
6448
+ if (keys.length === 1) return { ...options, context: keys[0] };
6449
+ if (!keys.length) {
6450
+ throw new Error(
6451
+ `No Topolo context is signed in for ${environment}, so there is nothing to pin. Run \`topolo --env ${environment} auth login\` first, or pass --context <context>.`
6452
+ );
6453
+ }
6454
+ throw new Error(
6455
+ `${keys.length} Topolo contexts are signed in for ${environment} (${keys.join(", ")}). Pass --context <context> to choose the one this MCP registration pins.`
6456
+ );
6457
+ }
6458
+
5971
6459
  // src/commands/auth.ts
5972
6460
  function registerAuthCommands(program2) {
5973
6461
  const auth = program2.command("auth").description("Manage Topolo credentials");
6462
+ registerAuthLockCommand(auth);
5974
6463
  auth.command("login").description("Authenticate with Topolo via OAuth device flow (default) or store an API key").option("--api-key <key>", "Store an API key directly, skipping the OAuth flow").option("--client-id <id>", "OAuth client_id (or set TOPOLO_CLI_CLIENT_ID)").option("--scope <scope>", "Scopes to request (space-separated)").option("--no-browser", "Print the approval URL without opening a browser").option("--use", "Also make the approved context this environment's stored default (as `topolo context use`)").option("--json", "Output JSON").option("--no-json", "Output human-readable text").addHelpText("after", [
5975
6464
  "",
5976
6465
  "The approved sign-in is stored under its own context key (personal or org:<id>).",
@@ -6021,16 +6510,37 @@ Visit ${apiKeyIssuanceUrl(environment)} to mint one, or configure an OAuth clien
6021
6510
  }
6022
6511
  }
6023
6512
  );
6024
- auth.command("logout").description("Remove stored credentials from this machine").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
6513
+ auth.command("logout").description("Remove every stored sign-in for this environment and revoke it at Topolo").option("--json", "Output JSON").option("--no-json", "Output human-readable text").addHelpText("after", [
6514
+ "",
6515
+ "Removes the stored credential, every personal and organization context and any",
6516
+ "pending re-authorization for the selected environment, then revokes each removed",
6517
+ "OAuth sign-in at Topolo. Stored API keys are only removed from this machine.",
6518
+ "TOPOLO_* credential environment variables are not affected."
6519
+ ].join("\n")).action(async (opts) => {
6025
6520
  const mode = resolveOutputMode(opts.json);
6026
6521
  const environment = selectedEnvironment();
6027
6522
  try {
6028
- const config = await readConfig();
6029
- await writeConfig(clearProfileCredential(config, environment));
6523
+ const result = await signOut(environment);
6524
+ const source = credentialSourceFromConfig(await readConfig(), environment);
6525
+ const environmentCredential = source.startsWith("env:") ? source.slice(4) : null;
6526
+ if (result.revocationFailed > 0) process.exitCode = 1;
6030
6527
  emit(
6031
- { ok: true, environment },
6528
+ {
6529
+ ok: result.revocationFailed === 0,
6530
+ removedLocally: true,
6531
+ environment,
6532
+ removed: result.contexts,
6533
+ revoked: result.revoked,
6534
+ revocationFailed: result.revocationFailed,
6535
+ ...environmentCredential ? { environmentCredential } : {}
6536
+ },
6032
6537
  mode,
6033
- () => `Logged out of Topolo ${environmentLabel(environment)}.`
6538
+ () => [
6539
+ result.contexts.length ? `Logged out of Topolo ${environmentLabel(environment)}: removed ${result.contexts.join(", ")}.` : `No stored sign-in for Topolo ${environmentLabel(environment)}.`,
6540
+ ...result.revoked ? [`Revoked ${result.revoked} sign-in${result.revoked === 1 ? "" : "s"} at Topolo.`] : [],
6541
+ ...result.revocationFailed ? [`Could not confirm revocation of ${result.revocationFailed} sign-in${result.revocationFailed === 1 ? "" : "s"} at Topolo; they are removed from this machine and expire on their own. Revoke them from your Topolo account to end them now.`] : [],
6542
+ ...environmentCredential ? [`${environmentCredential} is still set, so commands keep using it until you unset it.`] : []
6543
+ ].join("\n")
6034
6544
  );
6035
6545
  } catch (err) {
6036
6546
  fail2(err, mode);
@@ -6066,13 +6576,23 @@ Visit ${apiKeyIssuanceUrl(environment)} to mint one, or configure an OAuth clien
6066
6576
  async function storeApiKey(apiKey, environment, mode) {
6067
6577
  if (!apiKey) throw new Error("API key is required");
6068
6578
  const config = await readConfig();
6069
- await writeConfig(
6070
- writeProfileConfig(config, environment, {
6071
- credential: { kind: "api_key", apiKey }
6072
- })
6073
- );
6579
+ const next = writeProfileConfig(config, environment, {
6580
+ credential: { kind: "api_key", apiKey }
6581
+ });
6582
+ const profile = next.profiles?.[environment];
6583
+ if (profile) delete profile.activeContext;
6584
+ await writeConfig(next);
6585
+ const overriddenBy = [];
6586
+ const source = credentialSourceFromConfig(next, environment);
6587
+ if (source.startsWith("env:")) overriddenBy.push(source.slice(4));
6588
+ const pinned = process.env.TOPOLO_CONTEXT?.trim();
6589
+ if (pinned) overriddenBy.push(`TOPOLO_CONTEXT=${pinned}`);
6590
+ for (const name of overriddenBy) {
6591
+ process.stderr.write(`Warning: ${name} is set, so commands use it instead of the stored API key until you unset it.
6592
+ `);
6593
+ }
6074
6594
  emit(
6075
- { ok: true, environment, credential: { kind: "api_key" } },
6595
+ { ok: true, environment, credential: { kind: "api_key" }, ...overriddenBy.length ? { overriddenBy } : {} },
6076
6596
  mode,
6077
6597
  () => `Stored API key for Topolo ${environmentLabel(environment)}. Run \`topolo whoami\` to verify.`
6078
6598
  );
@@ -6138,7 +6658,32 @@ async function runDeviceFlow(options) {
6138
6658
  options.environment
6139
6659
  );
6140
6660
  const approvedKey = contextKey(context.contextType, context.organizationId);
6661
+ const approvedUser = identity?.user.id || accessTokenSubject(credential.accessToken);
6662
+ if (approvedUser) context.userId = approvedUser;
6141
6663
  const updated = await readConfig();
6664
+ const storedUser = contextUser(updated, options.environment, approvedKey);
6665
+ if (storedUser && storedUser !== approvedUser) {
6666
+ const who2 = identity ? identity.user.email : "an identity that could not be verified";
6667
+ process.stderr.write(
6668
+ `
6669
+ \u26A0 The browser approved ${who2}, but ${approvedKey} on this machine belongs to a different user.
6670
+ Nothing was stored. Approve as that user, or run \`topolo --env ${options.environment} auth logout\` first to replace it.
6671
+ `
6672
+ );
6673
+ process.exitCode = 1;
6674
+ emit(
6675
+ {
6676
+ ok: false,
6677
+ environment: options.environment,
6678
+ error: "user_mismatch",
6679
+ approved: { contextKey: approvedKey, user: identity ? { email: identity.user.email, name: identity.user.name } : null },
6680
+ ...options.requestedContext ? { requestedContext: options.requestedContext, requestedContextRenewed: false } : {}
6681
+ },
6682
+ options.mode,
6683
+ () => ` ${approvedKey} was not changed.`
6684
+ );
6685
+ return;
6686
+ }
6142
6687
  const previousDefault = profileDefault(updated, options.environment);
6143
6688
  const makeDefault = options.makeDefault === true || signedInContextKeys(updated, options.environment).length === 0;
6144
6689
  await writeConfig(writeContextCredential(updated, options.environment, context, makeDefault));
@@ -6186,6 +6731,12 @@ async function runDeviceFlow(options) {
6186
6731
  ].join("\n")
6187
6732
  );
6188
6733
  }
6734
+ function contextUser(config, environment, key) {
6735
+ const profile = config.profiles?.[environment];
6736
+ const stored = profile?.contexts?.[key];
6737
+ const credential = stored?.credential;
6738
+ return stored?.userId ?? (credential?.kind === "access_token" ? accessTokenSubject(credential.accessToken) : void 0) ?? profile?.reauthorization?.[key]?.expectedSubject;
6739
+ }
6189
6740
  function profileDefault(config, environment) {
6190
6741
  return config.profiles?.[environment]?.activeContext;
6191
6742
  }
@@ -6890,7 +7441,7 @@ function formatActionPlan(plan) {
6890
7441
  }
6891
7442
 
6892
7443
  // src/lib/credential-custody.ts
6893
- import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
7444
+ import { createHash as createHash4, randomUUID as randomUUID3 } from "crypto";
6894
7445
  import { constants as constants2 } from "fs";
6895
7446
  import { chmod as chmod3, lstat as lstat2, mkdir as mkdir3, open, rename as rename2, rm as rm2 } from "fs/promises";
6896
7447
  import { basename as basename2, isAbsolute, join as join5, normalize, sep } from "path";
@@ -6966,7 +7517,7 @@ async function writeCredentialCustody(request, deps) {
6966
7517
  environment: deps.environment,
6967
7518
  apiKeyId: key.id,
6968
7519
  expiresAt: key.expiresAt,
6969
- keyDigest: `sha256:${createHash3("sha256").update(secret, "utf8").digest("hex")}`,
7520
+ keyDigest: `sha256:${createHash4("sha256").update(secret, "utf8").digest("hex")}`,
6970
7521
  createdAt: key.createdAt
6971
7522
  };
6972
7523
  await writeCustodyFiles(directory, [
@@ -7722,7 +8273,7 @@ import { spawnSync as spawnSync5 } from "child_process";
7722
8273
  import { existsSync as existsSync6 } from "fs";
7723
8274
  import { readFile as readFile9, rm as rm3 } from "fs/promises";
7724
8275
  import { homedir as homedir5 } from "os";
7725
- import { dirname as dirname7, join as join11, resolve as resolve3 } from "path";
8276
+ import { dirname as dirname7, join as join11, resolve as resolve4 } from "path";
7726
8277
  import { fileURLToPath as fileURLToPath3 } from "url";
7727
8278
  import { createRequire } from "module";
7728
8279
 
@@ -7737,7 +8288,7 @@ import { spawnSync as spawnSync3 } from "child_process";
7737
8288
  import { existsSync as existsSync4 } from "fs";
7738
8289
  import { readFile as readFile4 } from "fs/promises";
7739
8290
  import { homedir as homedir4 } from "os";
7740
- import { isAbsolute as isAbsolute2, join as join7, resolve as resolve2 } from "path";
8291
+ import { isAbsolute as isAbsolute2, join as join7, resolve as resolve3 } from "path";
7741
8292
  var SUPPORTED_AGENT_IDS = [
7742
8293
  "claude",
7743
8294
  "claude-desktop",
@@ -7778,14 +8329,14 @@ var AGENT_NAME_FOR = {
7778
8329
  "amazon-q": "amazon-q"
7779
8330
  };
7780
8331
  function resolveAgentHostTarget(options) {
7781
- const homeDir = resolve2(options.homeDir ?? homedir4());
8332
+ const homeDir = resolve3(options.homeDir ?? homedir4());
7782
8333
  const codexHome = options.codexHome ?? (options.homeDir === void 0 ? process.env.CODEX_HOME || void 0 : void 0);
7783
8334
  if (codexHome !== void 0 && (!codexHome.trim() || !isAbsolute2(codexHome))) {
7784
8335
  throw new Error("Codex home must be an absolute nonempty directory.");
7785
8336
  }
7786
8337
  return Object.freeze({
7787
8338
  homeDir,
7788
- ...options.projectDir ? { projectDir: resolve2(options.projectDir) } : {},
8339
+ ...options.projectDir ? { projectDir: resolve3(options.projectDir) } : {},
7789
8340
  codexHome: codexHome ?? join7(homeDir, ".codex")
7790
8341
  });
7791
8342
  }
@@ -7947,7 +8498,7 @@ async function assertClaudeProjectScope(homeDir, projectDir, name) {
7947
8498
  }
7948
8499
  return value;
7949
8500
  };
7950
- const local = object(object(config.projects)?.[resolve2(projectDir)]);
8501
+ const local = object(object(config.projects)?.[resolve3(projectDir)]);
7951
8502
  if (Object.hasOwn(object(config.mcpServers) ?? {}, name) || Object.hasOwn(object(local?.mcpServers) ?? {}, name)) {
7952
8503
  throw new Error("Claude MCP scope conflict for " + name + ": a user or local definition can override the project registration. Resolve that scope before setup.");
7953
8504
  }
@@ -9040,8 +9591,8 @@ function formatSharedSetup(result) {
9040
9591
  var AGENT_LABELS2 = AGENT_LABELS;
9041
9592
  function skillsSourceDir() {
9042
9593
  const here = dirname7(fileURLToPath3(import.meta.url));
9043
- const distCandidate = resolve3(here, "../skills");
9044
- const srcCandidate = resolve3(here, "../../skills");
9594
+ const distCandidate = resolve4(here, "../skills");
9595
+ const srcCandidate = resolve4(here, "../../skills");
9045
9596
  for (const candidate of [distCandidate, srcCandidate]) {
9046
9597
  if (existsSync6(join11(candidate, "topolo", "SKILL.md"))) return candidate;
9047
9598
  }
@@ -9168,7 +9719,7 @@ function registerSkillsCommand(program2) {
9168
9719
  const mode = resolveOutputMode(opts.json);
9169
9720
  try {
9170
9721
  const rows = await agentSurfaceStatus({
9171
- homeDir: opts.agentHome ? resolve3(opts.agentHome) : void 0,
9722
+ homeDir: opts.agentHome ? resolve4(opts.agentHome) : void 0,
9172
9723
  codexHome: opts.codexHome,
9173
9724
  name: opts.name,
9174
9725
  agent: opts.agent,
@@ -9185,7 +9736,7 @@ function registerSkillsCommand(program2) {
9185
9736
  try {
9186
9737
  const removed = await uninstallAgentSurfaces({
9187
9738
  ...opts,
9188
- homeDir: opts.agentHome ? resolve3(opts.agentHome) : void 0
9739
+ homeDir: opts.agentHome ? resolve4(opts.agentHome) : void 0
9189
9740
  });
9190
9741
  emit(
9191
9742
  { removed },
@@ -9328,7 +9879,7 @@ function formatStatus(rows) {
9328
9879
  // src/commands/doctor.ts
9329
9880
  import { access as access2 } from "fs/promises";
9330
9881
  import { homedir as homedir6 } from "os";
9331
- import { resolve as resolve4 } from "path";
9882
+ import { resolve as resolve5 } from "path";
9332
9883
  import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
9333
9884
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
9334
9885
 
@@ -9504,7 +10055,7 @@ async function verifyMcpProtocol(command, args, env = definedEnvironment(process
9504
10055
  }
9505
10056
  async function resolveInstalledDoctorTarget(options) {
9506
10057
  const name = options.name?.trim() || "topolo";
9507
- const homeDir = options.agentHome ? resolve4(options.agentHome) : homedir6();
10058
+ const homeDir = options.agentHome ? resolve5(options.agentHome) : homedir6();
9508
10059
  const expected = resolveMcpCommand();
9509
10060
  const requestedAgents = resolveTargets({
9510
10061
  ...options,
@@ -9610,12 +10161,12 @@ function credentialFromTokens(tokens) {
9610
10161
  const accessToken = tokens.accessToken || tokens.access_token;
9611
10162
  const refreshToken = tokens.refreshToken || tokens.refresh_token;
9612
10163
  const expiresIn = tokens.expiresIn || tokens.expires_in;
9613
- const expiresAtRaw = tokens.expiresAt || tokens.expires_at;
10164
+ const expiresAt = (tokens.expiresAt || tokens.expires_at) ?? (expiresIn ? Math.floor(Date.now() / 1e3) + expiresIn : accessTokenExpiry(accessToken));
9614
10165
  return {
9615
10166
  kind: "access_token",
9616
10167
  accessToken,
9617
10168
  ...refreshToken ? { refreshToken } : {},
9618
- ...expiresAtRaw ? { expiresAt: expiresAtRaw } : expiresIn ? { expiresAt: Math.floor(Date.now() / 1e3) + expiresIn } : {}
10169
+ ...expiresAt ? { expiresAt } : {}
9619
10170
  };
9620
10171
  }
9621
10172
  async function exchangeForContext(credential, serviceUrls, target) {
@@ -9845,6 +10396,8 @@ function registerContextCommand(program2) {
9845
10396
  }
9846
10397
  resolved.credential = newCredential;
9847
10398
  resolved.oauthClientId = existing?.oauthClientId ?? profile.oauthClientId;
10399
+ const userId = accessTokenSubject(newCredential.accessToken);
10400
+ if (userId) resolved.userId = userId;
9848
10401
  const updated = writeContextCredential(await readConfig(), environment, resolved, true);
9849
10402
  await writeConfig(updated);
9850
10403
  emit(
@@ -10155,7 +10708,7 @@ function registerInstallationsCommand(program2) {
10155
10708
  import { createTopoloMcpLocalHandler } from "@topolo/mcp/local";
10156
10709
 
10157
10710
  // src/lib/mcp-hub-server.ts
10158
- import { createHash as createHash4, timingSafeEqual } from "crypto";
10711
+ import { createHash as createHash5, timingSafeEqual } from "crypto";
10159
10712
  import { stat as stat2 } from "fs/promises";
10160
10713
  import { createServer } from "http";
10161
10714
  var MCP_HUB_HOST = "127.0.0.1";
@@ -10170,7 +10723,7 @@ function refusal(status, message3, headers = {}) {
10170
10723
  return json(status, { jsonrpc: "2.0", error: { code: -32e3, message: message3 }, id: null }, headers);
10171
10724
  }
10172
10725
  function digest(value) {
10173
- return createHash4("sha256").update(value, "utf8").digest();
10726
+ return createHash5("sha256").update(value, "utf8").digest();
10174
10727
  }
10175
10728
  function bearerTokenMatches(header, token) {
10176
10729
  const match = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(header ?? "");
@@ -10301,11 +10854,11 @@ async function writeWebResponse(response, target) {
10301
10854
  }
10302
10855
  async function startMcpHub(options) {
10303
10856
  const server = createServer();
10304
- await new Promise((resolve5, reject) => {
10857
+ await new Promise((resolve6, reject) => {
10305
10858
  server.once("error", reject);
10306
10859
  server.listen(options.port, MCP_HUB_HOST, () => {
10307
10860
  server.off("error", reject);
10308
- resolve5();
10861
+ resolve6();
10309
10862
  });
10310
10863
  });
10311
10864
  const port = server.address().port;
@@ -10336,8 +10889,8 @@ async function startMcpHub(options) {
10336
10889
  return {
10337
10890
  port,
10338
10891
  server,
10339
- close: () => new Promise((resolve5) => {
10340
- server.close(() => resolve5());
10892
+ close: () => new Promise((resolve6) => {
10893
+ server.close(() => resolve6());
10341
10894
  server.closeAllConnections();
10342
10895
  })
10343
10896
  };