@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.
- package/README.md +10 -2
- package/dist/index.js +379 -65
- 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 #
|
|
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
|
-
|
|
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.
|
|
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(() =>
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
4844
|
-
config.
|
|
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
|
|
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
|
|
5023
|
-
const next =
|
|
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
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
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 =
|
|
5057
|
-
var AUTH_EXCHANGE_TIMEOUT_MS =
|
|
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
|
-
|
|
5436
|
-
} catch
|
|
5437
|
-
|
|
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
|
|
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) => ({
|
|
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
|
|
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:
|
|
5645
|
-
refreshToken:
|
|
5646
|
-
expiresAt: nowSeconds() +
|
|
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 (
|
|
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
|
|
5890
|
+
function accessTokenClaims(accessToken) {
|
|
5673
5891
|
try {
|
|
5674
|
-
const payload = accessToken
|
|
5675
|
-
if (!payload) return
|
|
5892
|
+
const payload = accessToken?.split(".")[1];
|
|
5893
|
+
if (!payload) return null;
|
|
5676
5894
|
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
5677
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
6270
|
-
await
|
|
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
|
-
{
|
|
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
|
-
() =>
|
|
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
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
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
|
|
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
|
-
...
|
|
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.
|
|
5
|
-
"topoloPublishedVersion": "0.13.
|
|
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.
|
|
37
|
-
"@topolo/sdk": "0.23.
|
|
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
|
},
|