@topolo/cli 0.13.23 → 0.13.25

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.
Files changed (3) hide show
  1. package/README.md +10 -2
  2. package/dist/index.js +379 -65
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -57,11 +57,19 @@ For interactive use:
57
57
  topolo auth login # OAuth device flow — no client id needed; approve in the browser
58
58
  topolo auth login --api-key topo_live_... # or store an API key directly (headless/service)
59
59
  topolo auth status # shows which source is active
60
- topolo auth logout # clears the stored credential
60
+ topolo auth logout # removes every stored sign-in for the environment and revokes it
61
61
  topolo auth lock # shows who holds the sign-in renewal lock and how to recover
62
62
  ```
63
63
 
64
- If every command fails with `state_conflict` after about 15 seconds, the lock
64
+ A stored API key is what unpinned commands act as, even when OAuth contexts are
65
+ also stored; name a context with `--context` to use one. `auth logout` removes
66
+ the stored credential, every personal and organization context and any pending
67
+ re-authorization for the selected environment, then revokes each removed OAuth
68
+ sign-in at Topolo (API keys are only removed locally). A re-authorization is
69
+ accepted only from the user who signed the context in; another member of the
70
+ same organization approving in the browser changes nothing.
71
+
72
+ If every command fails with `state_conflict` after about 30 seconds, the lock
65
73
  that serializes sign-in renewal is held or was left behind. `topolo auth lock`
66
74
  shows its state and the next step; an empty lock left by an interrupted writer
67
75
  is recovered with `topolo auth lock --reconcile <generation>`, which keeps the
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/.pnpm/@topolo+sdk@0.23.25/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);
@@ -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(), {
@@ -4020,7 +4044,7 @@ async function pollUntilApproved(params) {
4020
4044
  // src/commands/auth-lock.ts
4021
4045
  import { createHash as createHash3 } from "crypto";
4022
4046
 
4023
- // node_modules/.pnpm/@topolo+sdk@0.23.25/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
4024
4048
  import { mkdir as mkdir2, readFile as readFile2, chmod as chmod2, lstat, readdir as readdir2 } from "fs/promises";
4025
4049
  import { existsSync as existsSync2, mkdirSync, lstatSync, readdirSync, readFileSync as readFileSync2, openSync, writeFileSync, fsyncSync, closeSync, renameSync, unlinkSync, rmdirSync, linkSync } from "fs";
4026
4050
  import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
@@ -4354,7 +4378,7 @@ var TopoloRateLimitError2 = class extends TopoloHttpError2 {
4354
4378
  this.retryAfterSeconds = topoloRetryAfterSeconds2(this.retryAfter);
4355
4379
  }
4356
4380
  };
4357
- var TopoloReauthRequiredError = class extends TopoloAuthError2 {
4381
+ var TopoloReauthRequiredError2 = class extends TopoloAuthError2 {
4358
4382
  reauthorization;
4359
4383
  constructor(message3, reauthorization, options = {}) {
4360
4384
  super(message3, "reauth_required", { status: 401, service: "auth", path: "/api/developer-oauth/token", ...options });
@@ -4504,6 +4528,19 @@ var TopoloOAuth2 = class {
4504
4528
  if (params.clientSecret) body.set("client_secret", params.clientSecret);
4505
4529
  return this.#postForm("/api/developer-oauth/token", body);
4506
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
+ }
4507
4544
  async #postForm(path, body) {
4508
4545
  const url = new URL(path, this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`);
4509
4546
  const res = await this.fetchImpl(url.toString(), {
@@ -4802,11 +4839,15 @@ async function readConfig() {
4802
4839
  if (!existsSync2(path)) return {};
4803
4840
  try {
4804
4841
  const raw = await readFile2(path, "utf8");
4805
- return JSON.parse(raw);
4842
+ const parsed = JSON.parse(raw);
4843
+ return isConfigObject(parsed) ? parsed : {};
4806
4844
  } catch {
4807
4845
  return {};
4808
4846
  }
4809
4847
  }
4848
+ function isConfigObject(value) {
4849
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
4850
+ }
4810
4851
  async function writeConfig(config) {
4811
4852
  const path = configPath();
4812
4853
  await mkdir2(dirname2(path), { recursive: true });
@@ -4838,10 +4879,21 @@ function retiredEntry(config, refreshToken) {
4838
4879
  const entry = config.retiredRefreshTokens?.[refreshTokenFingerprint(refreshToken)];
4839
4880
  return entry && typeof entry === "object" ? entry : void 0;
4840
4881
  }
4841
- function retire(config, refreshToken, reason) {
4882
+ function retire(config, refreshToken, reason, inFlight = false) {
4842
4883
  const now = nowSeconds();
4843
- 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);
4844
- 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;
4845
4897
  }
4846
4898
  function environmentSlots(config, environment) {
4847
4899
  const slots = [];
@@ -4923,6 +4975,20 @@ function loginCommand(environment, key) {
4923
4975
  const use = environment === "production" ? "topolo context use" : `topolo --env ${environment} context use`;
4924
4976
  return `${base} && ${use} ${key.slice(4)}`;
4925
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
+ }
4926
4992
  function contextLabel(key) {
4927
4993
  return key === DEFAULT_SLOT ? "default" : key;
4928
4994
  }
@@ -4934,6 +5000,8 @@ function reauthRequired(environment, key, entry, state) {
4934
5000
  let action;
4935
5001
  if (state === "awaiting_approval" && pending) {
4936
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.`;
4937
5005
  } else if (state === "organization_mismatch") {
4938
5006
  action = ` The last approval was for ${entry?.mismatch?.approvedContext ?? "another context"}, not ${contextLabel(key)}; approve again with that organization selected.`;
4939
5007
  } else if (state === "denied") {
@@ -4941,7 +5009,7 @@ function reauthRequired(environment, key, entry, state) {
4941
5009
  } else {
4942
5010
  action = "";
4943
5011
  }
4944
- 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)}).`, {
4945
5013
  environment,
4946
5014
  context: contextLabel(key),
4947
5015
  reason,
@@ -5019,18 +5087,94 @@ function writeContextCredential(config, environment, ctx, makeActive = true) {
5019
5087
  }
5020
5088
  return writeProfileConfig(config, environment, next);
5021
5089
  }
5022
- function clearProfileCredential(config, environment) {
5023
- const next = { ...config };
5090
+ function clearEnvironmentSignIn(config, environment) {
5091
+ const next = clone(config);
5024
5092
  if (environment === "production") {
5025
5093
  delete next.credential;
5026
5094
  delete next.oauthClientId;
5027
5095
  }
5028
- const profiles = { ...config.profiles ?? {} };
5029
- const profile = { ...profiles[environment] ?? {} };
5030
- delete profile.credential;
5031
- delete profile.oauthClientId;
5032
- profiles[environment] = profile;
5033
- 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;
5034
5178
  }
5035
5179
  function credentialSourceFromConfig(config, environment = selectedEnvironment()) {
5036
5180
  const prefix = envVarPrefix(environment);
@@ -5053,8 +5197,8 @@ async function resolveCredential(environment = selectedEnvironment()) {
5053
5197
  }
5054
5198
  var REFRESH_SKEW_SECONDS = 60;
5055
5199
  var CONFIG_LOCK_RETRY_MS = 25;
5056
- var CONFIG_LOCK_DEFAULT_TIMEOUT_MS = 15e3;
5057
- var AUTH_EXCHANGE_TIMEOUT_MS = 1e4;
5200
+ var CONFIG_LOCK_DEFAULT_TIMEOUT_MS = 3e4;
5201
+ var AUTH_EXCHANGE_TIMEOUT_MS = 2e4;
5058
5202
  function configLockTimeoutMs() {
5059
5203
  const value = Number(process.env.TOPOLO_CONFIG_LOCK_TIMEOUT_MS);
5060
5204
  return Number.isFinite(value) && value >= 1e3 && value <= 12e4 ? value : CONFIG_LOCK_DEFAULT_TIMEOUT_MS;
@@ -5062,6 +5206,8 @@ function configLockTimeoutMs() {
5062
5206
  function boundedOAuth(serviceUrls) {
5063
5207
  const fetchImpl = (input2, init) => globalThis.fetch(input2, {
5064
5208
  ...init,
5209
+ // Never re-POST a refresh token, device code or revoked token to another origin.
5210
+ redirect: "error",
5065
5211
  signal: init?.signal ?? AbortSignal.timeout(AUTH_EXCHANGE_TIMEOUT_MS)
5066
5212
  });
5067
5213
  return new TopoloOAuth2({ serviceUrls, fetch: fetchImpl });
@@ -5349,6 +5495,12 @@ function reclaimDeadOwnerLock(lockPath, markerName, marker) {
5349
5495
  return reclaimed;
5350
5496
  }
5351
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
+ }
5352
5504
  var PERSIST_ATTEMPTS = 3;
5353
5505
  async function withConfigWriteLock(operation) {
5354
5506
  const lockPath = `${configPath()}.lock`;
@@ -5431,11 +5583,14 @@ async function withConfigWriteLock(operation) {
5431
5583
  const before = snapshot();
5432
5584
  let current = {};
5433
5585
  if (before !== null) {
5586
+ let parsed = {};
5434
5587
  try {
5435
- current = JSON.parse(before);
5436
- } catch (error) {
5437
- if (!options.allowCreate) throw error;
5588
+ if (before.trim()) parsed = JSON.parse(before);
5589
+ } catch {
5590
+ throw unreadableConfig(path);
5438
5591
  }
5592
+ if (!isConfigObject(parsed)) throw unreadableConfig(path);
5593
+ current = parsed;
5439
5594
  }
5440
5595
  const updated = update(current);
5441
5596
  const fd = openSync(temporary, "wx", 384);
@@ -5517,7 +5672,8 @@ async function withConfigWriteLock(operation) {
5517
5672
  function renewalFailure(error) {
5518
5673
  const path = "/api/developer-oauth/token";
5519
5674
  if (error instanceof ConfigOwnershipError) return new ConfigOwnershipError();
5520
- if (error instanceof TopoloReauthRequiredError) return error;
5675
+ if (error instanceof TopoloReauthRequiredError2) return error;
5676
+ if (error instanceof TopoloSdkError2 && error.code === "config_unreadable") return error;
5521
5677
  if ((error instanceof TopoloAuthError2 || error instanceof TopoloHttpError2) && error.status === 429) {
5522
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.";
5523
5679
  return new TopoloRateLimitError2(
@@ -5593,6 +5749,35 @@ function singleFlight(key, run) {
5593
5749
  renewalsInFlight.set(key, started);
5594
5750
  return started;
5595
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
+ }
5596
5781
  async function renewTarget(environment, target, options) {
5597
5782
  const observedProfile = profileForEnvironment(await readConfig(), environment);
5598
5783
  const observedTarget = resolveTarget(observedProfile, target);
@@ -5614,6 +5799,7 @@ async function renewTarget(environment, target, options) {
5614
5799
  if (options.force && observed.kind === "access_token" && observed.accessToken !== credential.accessToken && credential.expiresAt !== void 0 && credential.expiresAt > now) return { credential };
5615
5800
  if (!options.force && credential.expiresAt && credential.expiresAt - REFRESH_SKEW_SECONDS > now) return { credential };
5616
5801
  const sent = credential.refreshToken;
5802
+ const subject = accessTokenSubject(credential.accessToken) ?? resolved.context?.userId;
5617
5803
  const endGrant = async (reason) => {
5618
5804
  await persist((current) => {
5619
5805
  const next = clone(current);
@@ -5621,35 +5807,67 @@ async function renewTarget(environment, target, options) {
5621
5807
  touched.add(resolved.key);
5622
5808
  retire(next, sent, reason);
5623
5809
  for (const key of touched) {
5624
- 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
+ }));
5625
5816
  }
5626
5817
  return next;
5627
5818
  });
5628
5819
  return { reauthorize: resolved.key };
5629
5820
  };
5821
+ const outcomeUnknown = async () => {
5822
+ if (!options.force && credential.expiresAt && credential.expiresAt > nowSeconds()) return { credential };
5823
+ return endGrant("rotated_grant_lost");
5824
+ };
5630
5825
  const retired = retiredEntry(config, sent);
5631
5826
  if (retired) {
5632
5827
  return endGrant(retired.reason === "rotated" ? "rotated_grant_lost" : retired.reason);
5633
5828
  }
5634
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
+ }
5635
5840
  let tokens;
5636
5841
  try {
5637
- tokens = await boundedOAuth(serviceUrls).refreshToken({ clientId: resolved.oauthClientId, refreshToken: sent });
5842
+ tokens = await oauth.refreshToken({ clientId: resolved.oauthClientId, refreshToken: sent });
5638
5843
  } catch (error) {
5639
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
+ }
5640
5855
  throw renewalFailure(error);
5641
5856
  }
5857
+ if (!usableTokenResponse(tokens)) return outcomeUnknown();
5858
+ const issued = tokens;
5642
5859
  const refreshed = {
5643
5860
  kind: "access_token",
5644
- accessToken: tokens.access_token,
5645
- refreshToken: tokens.refresh_token ?? sent,
5646
- expiresAt: nowSeconds() + tokens.expires_in
5861
+ accessToken: issued.access_token,
5862
+ refreshToken: issued.refresh_token ?? sent,
5863
+ expiresAt: nowSeconds() + issued.expires_in
5647
5864
  };
5648
5865
  try {
5649
5866
  await persist((current) => {
5650
5867
  const next = clone(current);
5651
5868
  replaceGrant(next, environment, sent, refreshed);
5652
- 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);
5653
5871
  return next;
5654
5872
  });
5655
5873
  } catch (error) {
@@ -5669,17 +5887,30 @@ async function refreshCredentialIfNeeded(environment = selectedEnvironment(), op
5669
5887
  async function refreshContextCredentialIfNeeded(environment, key, options = {}) {
5670
5888
  return renewTarget(environment, { kind: "context", key }, options);
5671
5889
  }
5672
- function decodeTokenOrganization(accessToken) {
5890
+ function accessTokenClaims(accessToken) {
5673
5891
  try {
5674
- const payload = accessToken.split(".")[1];
5675
- if (!payload) return { known: false, organizationId: null };
5892
+ const payload = accessToken?.split(".")[1];
5893
+ if (!payload) return null;
5676
5894
  const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
5677
- const org = claims.orgId ?? claims.organizationId ?? claims.org_id ?? null;
5678
- return { known: true, organizationId: typeof org === "string" && org ? org : null };
5895
+ return claims && typeof claims === "object" && !Array.isArray(claims) ? claims : null;
5679
5896
  } catch {
5680
- return { known: false, organizationId: null };
5897
+ return null;
5681
5898
  }
5682
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
+ }
5683
5914
  async function continueReauthorization(environment, key) {
5684
5915
  return withConfigWriteLock(async (_assertOwned, persist) => {
5685
5916
  const config = await readConfig();
@@ -5736,7 +5967,21 @@ async function continueReauthorization(environment, key) {
5736
5967
  };
5737
5968
  const organization = decodeTokenOrganization(tokens.access_token);
5738
5969
  const approvedKey = organization.known ? organization.organizationId ? `org:${organization.organizationId}` : "personal" : null;
5970
+ const approvedSubject = accessTokenSubject(tokens.access_token);
5739
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 } : {};
5740
5985
  await persist((current) => {
5741
5986
  const next = clone(current);
5742
5987
  const nextProfile = (next.profiles ??= {})[environment] ??= {};
@@ -5750,7 +5995,8 @@ async function continueReauthorization(environment, key) {
5750
5995
  nextProfile.contexts = { ...nextProfile.contexts ?? {}, [key]: {
5751
5996
  ...existing ?? (key.startsWith("org:") ? { contextType: "organization", organizationId: key.slice(4) } : { contextType: "personal" }),
5752
5997
  credential: approved,
5753
- oauthClientId: clientId
5998
+ oauthClientId: clientId,
5999
+ ...signedInAs
5754
6000
  } };
5755
6001
  if (nextProfile.activeContext === key) nextProfile.credential = approved;
5756
6002
  }
@@ -5761,7 +6007,8 @@ async function continueReauthorization(environment, key) {
5761
6007
  nextProfile.contexts = { ...nextProfile.contexts ?? {}, [approvedKey]: {
5762
6008
  ...existing ?? (approvedKey.startsWith("org:") ? { contextType: "organization", organizationId: approvedKey.slice(4) } : { contextType: "personal" }),
5763
6009
  credential: approved,
5764
- oauthClientId: clientId
6010
+ oauthClientId: clientId,
6011
+ ...signedInAs
5765
6012
  } };
5766
6013
  if (nextProfile.activeContext === approvedKey) nextProfile.credential = approved;
5767
6014
  }
@@ -5785,7 +6032,7 @@ async function continueReauthorization(environment, key) {
5785
6032
  throw reauthRequired(environment, key, state, pending.denied ? "denied" : "awaiting_approval");
5786
6033
  }
5787
6034
  if (state.startRetryAt && state.startRetryAt > now) {
5788
- throw reauthRequired(environment, key, state, state.mismatch ? "organization_mismatch" : "unavailable");
6035
+ throw reauthRequired(environment, key, state, mismatchState(state));
5789
6036
  }
5790
6037
  try {
5791
6038
  const started = await oauth.requestDeviceCode({ clientId });
@@ -5806,7 +6053,7 @@ async function continueReauthorization(environment, key) {
5806
6053
  await save(() => state);
5807
6054
  throw reauthRequired(environment, key, state, "awaiting_approval");
5808
6055
  } catch (error) {
5809
- if (error instanceof TopoloReauthRequiredError || error instanceof ConfigOwnershipError) throw error;
6056
+ if (error instanceof TopoloReauthRequiredError2 || error instanceof ConfigOwnershipError) throw error;
5810
6057
  const retryAfter = Number(error.retryAfter);
5811
6058
  state = { ...state, startRetryAt: now + Math.max(30, Number.isFinite(retryAfter) ? retryAfter : 0) };
5812
6059
  delete state.pending;
@@ -5824,7 +6071,7 @@ async function awaitReauthorization(environment, key, options = {}) {
5824
6071
  try {
5825
6072
  return await continueReauthorization(environment, slotKey);
5826
6073
  } catch (error) {
5827
- if (!(error instanceof TopoloReauthRequiredError)) throw error;
6074
+ if (!(error instanceof TopoloReauthRequiredError2)) throw error;
5828
6075
  const prompt = error.reauthorization;
5829
6076
  if (prompt.userCode !== lastCode) {
5830
6077
  lastCode = prompt.userCode;
@@ -6171,6 +6418,7 @@ function commandContextKey(config, environment, explicit = process.env.TOPOLO_CO
6171
6418
  }
6172
6419
  function implicitContextKey(config, environment) {
6173
6420
  if (credentialSourceFromConfig(config, environment).startsWith("env:")) return null;
6421
+ if (profileForEnvironment(config, environment).credential?.kind === "api_key") return null;
6174
6422
  const keys = signedInContextKeys(config, environment);
6175
6423
  if (keys.length > 1) throw new ContextNotSafeToAssumeError(environment, keys);
6176
6424
  return keys[0] ?? null;
@@ -6262,16 +6510,37 @@ Visit ${apiKeyIssuanceUrl(environment)} to mint one, or configure an OAuth clien
6262
6510
  }
6263
6511
  }
6264
6512
  );
6265
- 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) => {
6266
6520
  const mode = resolveOutputMode(opts.json);
6267
6521
  const environment = selectedEnvironment();
6268
6522
  try {
6269
- const config = await readConfig();
6270
- 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;
6271
6527
  emit(
6272
- { 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
+ },
6273
6537
  mode,
6274
- () => `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")
6275
6544
  );
6276
6545
  } catch (err) {
6277
6546
  fail2(err, mode);
@@ -6307,13 +6576,23 @@ Visit ${apiKeyIssuanceUrl(environment)} to mint one, or configure an OAuth clien
6307
6576
  async function storeApiKey(apiKey, environment, mode) {
6308
6577
  if (!apiKey) throw new Error("API key is required");
6309
6578
  const config = await readConfig();
6310
- await writeConfig(
6311
- writeProfileConfig(config, environment, {
6312
- credential: { kind: "api_key", apiKey }
6313
- })
6314
- );
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
+ }
6315
6594
  emit(
6316
- { ok: true, environment, credential: { kind: "api_key" } },
6595
+ { ok: true, environment, credential: { kind: "api_key" }, ...overriddenBy.length ? { overriddenBy } : {} },
6317
6596
  mode,
6318
6597
  () => `Stored API key for Topolo ${environmentLabel(environment)}. Run \`topolo whoami\` to verify.`
6319
6598
  );
@@ -6322,6 +6601,7 @@ async function runDeviceFlow(options) {
6322
6601
  const config = await readConfig();
6323
6602
  const resolved = resolvedProfileConfig(config, options.environment);
6324
6603
  const oauth = new TopoloOAuth({
6604
+ environment: options.environment,
6325
6605
  serviceUrls: resolved.serviceUrls
6326
6606
  });
6327
6607
  const start = await oauth.requestDeviceCode({
@@ -6379,7 +6659,32 @@ async function runDeviceFlow(options) {
6379
6659
  options.environment
6380
6660
  );
6381
6661
  const approvedKey = contextKey(context.contextType, context.organizationId);
6662
+ const approvedUser = identity?.user.id || accessTokenSubject(credential.accessToken);
6663
+ if (approvedUser) context.userId = approvedUser;
6382
6664
  const updated = await readConfig();
6665
+ const storedUser = contextUser(updated, options.environment, approvedKey);
6666
+ if (storedUser && storedUser !== approvedUser) {
6667
+ const who2 = identity ? identity.user.email : "an identity that could not be verified";
6668
+ process.stderr.write(
6669
+ `
6670
+ \u26A0 The browser approved ${who2}, but ${approvedKey} on this machine belongs to a different user.
6671
+ Nothing was stored. Approve as that user, or run \`topolo --env ${options.environment} auth logout\` first to replace it.
6672
+ `
6673
+ );
6674
+ process.exitCode = 1;
6675
+ emit(
6676
+ {
6677
+ ok: false,
6678
+ environment: options.environment,
6679
+ error: "user_mismatch",
6680
+ approved: { contextKey: approvedKey, user: identity ? { email: identity.user.email, name: identity.user.name } : null },
6681
+ ...options.requestedContext ? { requestedContext: options.requestedContext, requestedContextRenewed: false } : {}
6682
+ },
6683
+ options.mode,
6684
+ () => ` ${approvedKey} was not changed.`
6685
+ );
6686
+ return;
6687
+ }
6383
6688
  const previousDefault = profileDefault(updated, options.environment);
6384
6689
  const makeDefault = options.makeDefault === true || signedInContextKeys(updated, options.environment).length === 0;
6385
6690
  await writeConfig(writeContextCredential(updated, options.environment, context, makeDefault));
@@ -6427,6 +6732,12 @@ async function runDeviceFlow(options) {
6427
6732
  ].join("\n")
6428
6733
  );
6429
6734
  }
6735
+ function contextUser(config, environment, key) {
6736
+ const profile = config.profiles?.[environment];
6737
+ const stored = profile?.contexts?.[key];
6738
+ const credential = stored?.credential;
6739
+ return stored?.userId ?? (credential?.kind === "access_token" ? accessTokenSubject(credential.accessToken) : void 0) ?? profile?.reauthorization?.[key]?.expectedSubject;
6740
+ }
6430
6741
  function profileDefault(config, environment) {
6431
6742
  return config.profiles?.[environment]?.activeContext;
6432
6743
  }
@@ -9820,8 +10131,9 @@ function errorMessage(error) {
9820
10131
  }
9821
10132
 
9822
10133
  // src/commands/context.ts
9823
- function makeClient(credential, serviceUrls) {
10134
+ function makeClient(environment, credential, serviceUrls) {
9824
10135
  return createTopolo({
10136
+ environment,
9825
10137
  credential,
9826
10138
  agent: { clientName: "topolo-cli", clientVersion: CLI_VERSION },
9827
10139
  serviceUrls
@@ -9831,8 +10143,8 @@ function unwrap(res) {
9831
10143
  const r = res ?? {};
9832
10144
  return r.data ?? r;
9833
10145
  }
9834
- async function fetchMe(credential, serviceUrls) {
9835
- const topolo = makeClient(credential, serviceUrls);
10146
+ async function fetchMe(environment, credential, serviceUrls) {
10147
+ const topolo = makeClient(environment, credential, serviceUrls);
9836
10148
  const data = unwrap(await topolo.client.request({ service: "auth", method: "GET", path: "/api/auth/me" }));
9837
10149
  const user = data.user ?? {};
9838
10150
  const rawMemberships = user.organizationMemberships || data.organizations || [];
@@ -9851,16 +10163,16 @@ function credentialFromTokens(tokens) {
9851
10163
  const accessToken = tokens.accessToken || tokens.access_token;
9852
10164
  const refreshToken = tokens.refreshToken || tokens.refresh_token;
9853
10165
  const expiresIn = tokens.expiresIn || tokens.expires_in;
9854
- const expiresAtRaw = tokens.expiresAt || tokens.expires_at;
10166
+ const expiresAt = (tokens.expiresAt || tokens.expires_at) ?? (expiresIn ? Math.floor(Date.now() / 1e3) + expiresIn : accessTokenExpiry(accessToken));
9855
10167
  return {
9856
10168
  kind: "access_token",
9857
10169
  accessToken,
9858
10170
  ...refreshToken ? { refreshToken } : {},
9859
- ...expiresAtRaw ? { expiresAt: expiresAtRaw } : expiresIn ? { expiresAt: Math.floor(Date.now() / 1e3) + expiresIn } : {}
10171
+ ...expiresAt ? { expiresAt } : {}
9860
10172
  };
9861
10173
  }
9862
- async function exchangeForContext(credential, serviceUrls, target) {
9863
- const topolo = makeClient(credential, serviceUrls);
10174
+ async function exchangeForContext(environment, credential, serviceUrls, target) {
10175
+ const topolo = makeClient(environment, credential, serviceUrls);
9864
10176
  const data = unwrap(
9865
10177
  await topolo.client.request({
9866
10178
  service: "auth",
@@ -9919,7 +10231,7 @@ function registerContextCommand(program2) {
9919
10231
  let activeIdentity = null;
9920
10232
  if (cred) {
9921
10233
  try {
9922
- activeIdentity = await fetchMe(cred, serviceUrls);
10234
+ activeIdentity = await fetchMe(environment, cred, serviceUrls);
9923
10235
  } catch {
9924
10236
  activeIdentity = null;
9925
10237
  }
@@ -9928,7 +10240,7 @@ function registerContextCommand(program2) {
9928
10240
  try {
9929
10241
  const contextCredential = key === activeKey ? cred : await refreshContextCredentialIfNeeded(environment, key);
9930
10242
  if (!contextCredential) return;
9931
- const identity = key === activeKey && activeIdentity ? activeIdentity : await fetchMe(contextCredential, serviceUrls);
10243
+ const identity = key === activeKey && activeIdentity ? activeIdentity : await fetchMe(environment, contextCredential, serviceUrls);
9932
10244
  if (!identityMatchesContext(identity, ctx)) return;
9933
10245
  const row = rows.get(key) ?? {
9934
10246
  key,
@@ -10024,7 +10336,7 @@ function registerContextCommand(program2) {
10024
10336
  } else if (wantPersonal) {
10025
10337
  resolved = { contextType: "personal" };
10026
10338
  } else {
10027
- const me = await fetchMe(cred, serviceUrls);
10339
+ const me = await fetchMe(environment, cred, serviceUrls);
10028
10340
  const match = me.memberships.find(
10029
10341
  (m) => m.id === target || m.slug?.toLowerCase() === needle || m.name?.toLowerCase() === needle
10030
10342
  );
@@ -10049,7 +10361,7 @@ function registerContextCommand(program2) {
10049
10361
  const storedCredential = await refreshContextCredentialIfNeeded(environment, key);
10050
10362
  if (storedCredential) {
10051
10363
  try {
10052
- const storedIdentity = await fetchMe(storedCredential, serviceUrls);
10364
+ const storedIdentity = await fetchMe(environment, storedCredential, serviceUrls);
10053
10365
  if (identityMatchesContext(storedIdentity, resolved)) {
10054
10366
  newCredential = storedCredential;
10055
10367
  }
@@ -10059,13 +10371,13 @@ function registerContextCommand(program2) {
10059
10371
  }
10060
10372
  if (!newCredential) {
10061
10373
  if (resolved.contextType === "personal") {
10062
- const identity = await fetchMe(cred, serviceUrls);
10374
+ const identity = await fetchMe(environment, cred, serviceUrls);
10063
10375
  if (!identity.personalAvailable) {
10064
10376
  throw new Error("Personal context is not available for this account.");
10065
10377
  }
10066
10378
  }
10067
10379
  try {
10068
- newCredential = await exchangeForContext(cred, serviceUrls, {
10380
+ newCredential = await exchangeForContext(environment, cred, serviceUrls, {
10069
10381
  type: resolved.contextType,
10070
10382
  id: resolved.organizationId
10071
10383
  });
@@ -10078,7 +10390,7 @@ function registerContextCommand(program2) {
10078
10390
  );
10079
10391
  }
10080
10392
  cred = refreshed;
10081
- newCredential = await exchangeForContext(cred, serviceUrls, {
10393
+ newCredential = await exchangeForContext(environment, cred, serviceUrls, {
10082
10394
  type: resolved.contextType,
10083
10395
  id: resolved.organizationId
10084
10396
  });
@@ -10086,6 +10398,8 @@ function registerContextCommand(program2) {
10086
10398
  }
10087
10399
  resolved.credential = newCredential;
10088
10400
  resolved.oauthClientId = existing?.oauthClientId ?? profile.oauthClientId;
10401
+ const userId = accessTokenSubject(newCredential.accessToken);
10402
+ if (userId) resolved.userId = userId;
10089
10403
  const updated = writeContextCredential(await readConfig(), environment, resolved, true);
10090
10404
  await writeConfig(updated);
10091
10405
  emit(
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@topolo/cli",
3
3
  "packageManager": "pnpm@10.17.1",
4
- "version": "0.13.23",
5
- "topoloPublishedVersion": "0.13.23",
4
+ "version": "0.13.25",
5
+ "topoloPublishedVersion": "0.13.25",
6
6
  "description": "Public command-line interface for Topolo customer organizations, developers, and agents.",
7
7
  "license": "UNLICENSED",
8
8
  "author": "Topolo",
@@ -33,8 +33,8 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@modelcontextprotocol/sdk": "1.31.0",
36
- "@topolo/mcp": "0.14.36",
37
- "@topolo/sdk": "0.23.25",
36
+ "@topolo/mcp": "0.14.37",
37
+ "@topolo/sdk": "0.23.26",
38
38
  "commander": "^12.1.0",
39
39
  "undici": "6.29.0"
40
40
  },