Skip to content

Commit ba16613

Browse files
author
wb-liuxuehuan
committed
feat(tokenplan): 重构 Token Plan 命令以支持新功能
对 `tokenplan` 相关命令进行了重构,新增了 `ak-sign` 模块以支持 ACS3-HMAC-SHA256 签名,优化了参数处理逻辑,简化了对凭证的处理。更新了 `add-member`、`assign-seats`、`create-key` 和 `seats` 命令,增强了对参数的验证和处理,确保代码的可读性和健壮性。同时,新增了类型定义和工具函数以支持更好的代码结构。
1 parent dc5a535 commit ba16613

14 files changed

Lines changed: 451 additions & 836 deletions

File tree

packages/cli/src/commands/tokenplan/add-member.ts

Lines changed: 27 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
import {
22
defineCommand,
3-
buildCanonicalQuery,
4-
signRequest,
5-
modelStudioHost,
63
detectOutputFormat,
7-
maskToken,
8-
trackingHeaders,
94
type Config,
105
type GlobalFlags,
11-
type AddOrganizationMemberResponse,
126
BailianError,
137
ExitCode,
148
} from "bailian-cli-core";
159
import { emitResult, emitBare } from "../../output/output.ts";
1610
import { padEnd } from "../../output/cjk-width.ts";
11+
import type { AddOrganizationMemberResponse } from "./types.ts";
12+
import {
13+
TOKEN_PLAN_AK_OPTIONS,
14+
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
15+
appendCommonQueryParams,
16+
callTokenPlanApi,
17+
prepareTokenPlanRequest,
18+
resolveTokenPlanCredentials,
19+
type TokenPlanQueryParams,
20+
} from "./utils.ts";
1721

18-
const API_VERSION = "2026-02-10";
1922
const API_ACTION = "AddOrganizationMember";
2023
const API_PATH = "/tokenplan/organization/member-additions";
2124

@@ -36,19 +39,8 @@ export default defineCommand({
3639
flag: "--spec-type <type>",
3740
description: "Seat tier to assign on creation: standard, pro, or max",
3841
},
39-
{
40-
flag: "--caller-uac-account-id <id>",
41-
description: "Caller UAC account ID",
42-
},
43-
{
44-
flag: "--namespace-id <id>",
45-
description: "Product namespace ID (Token Plan default: namespace-1)",
46-
},
47-
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
48-
{
49-
flag: "--access-key-secret <key>",
50-
description: "Alibaba Cloud Access Key Secret (deprecated)",
51-
},
42+
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
43+
...TOKEN_PLAN_AK_OPTIONS,
5244
],
5345
examples: [
5446
"bl tokenplan add-member --account-name dev_user --org-id org_123",
@@ -57,16 +49,7 @@ export default defineCommand({
5749
],
5850
async run(config: Config, flags: GlobalFlags) {
5951
const format = detectOutputFormat(config.output);
60-
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
61-
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
62-
63-
if (!accessKeyId || !accessKeySecret) {
64-
throw new BailianError(
65-
"No credentials found.\n" +
66-
"Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.",
67-
ExitCode.AUTH,
68-
);
69-
}
52+
const credentials = resolveTokenPlanCredentials(config, flags);
7053

7154
const accountName = flags.accountName as string | undefined;
7255
const orgId = flags.orgId as string | undefined;
@@ -78,52 +61,26 @@ export default defineCommand({
7861
}
7962

8063
const queryParams = buildQueryParams(flags);
81-
const queryString = buildCanonicalQuery(queryParams);
82-
const host = modelStudioHost(config.region);
83-
const endpoint = `https://${host}${API_PATH}${queryString ? `?${queryString}` : ""}`;
8464

8565
if (config.dryRun) {
86-
emitResult({ endpoint, query: queryParams }, format);
66+
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
67+
config,
68+
API_PATH,
69+
queryParams,
70+
);
71+
emitResult({ endpoint, query }, format);
8772
return;
8873
}
8974

90-
const headers = signRequest({
91-
accessKeyId,
92-
accessKeySecret,
75+
const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
76+
config,
77+
credentials,
9378
action: API_ACTION,
94-
version: API_VERSION,
95-
body: "",
96-
host,
97-
pathname: API_PATH,
79+
path: API_PATH,
9880
method: "POST",
99-
queryString,
81+
queryParams,
10082
});
10183

102-
if (config.verbose) {
103-
process.stderr.write(`> POST ${endpoint}\n`);
104-
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
105-
}
106-
107-
const timeoutMs = config.timeout * 1000;
108-
const res = await fetch(endpoint, {
109-
method: "POST",
110-
headers: { ...headers, ...trackingHeaders() },
111-
signal: AbortSignal.timeout(timeoutMs),
112-
});
113-
114-
if (config.verbose) {
115-
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
116-
}
117-
118-
const data = (await res.json()) as AddOrganizationMemberResponse;
119-
120-
if (!res.ok || data.Success === false) {
121-
throw new BailianError(
122-
`${data.Code || res.status} - ${data.Message || res.statusText}`,
123-
ExitCode.GENERAL,
124-
);
125-
}
126-
12784
if (config.quiet || format === "text") {
12885
emitTextMember(data);
12986
} else {
@@ -132,8 +89,8 @@ export default defineCommand({
13289
},
13390
});
13491

135-
function buildQueryParams(flags: GlobalFlags): Record<string, string | string[] | undefined> {
136-
const params: Record<string, string | string[] | undefined> = {};
92+
function buildQueryParams(flags: GlobalFlags): TokenPlanQueryParams {
93+
const params: TokenPlanQueryParams = {};
13794

13895
if (flags.accountName) params.AccountName = flags.accountName as string;
13996
if (flags.orgId) params.OrgId = flags.orgId as string;
@@ -142,8 +99,7 @@ function buildQueryParams(flags: GlobalFlags): Record<string, string | string[]
14299
? flags.orgRoleCode
143100
: DEFAULT_ORG_ROLE;
144101
if (flags.specType) params.SpecType = flags.specType as string;
145-
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
146-
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
102+
appendCommonQueryParams(params, flags);
147103

148104
return params;
149105
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
3+
*
4+
* Extends the core ROA signer with canonical query string support required by
5+
* Token Plan endpoints that pass parameters in the URL query.
6+
*/
7+
8+
import { createHmac, createHash, randomUUID } from "crypto";
9+
10+
export interface TokenPlanAkSignConfig {
11+
accessKeyId: string;
12+
accessKeySecret: string;
13+
action: string;
14+
version: string;
15+
body: string;
16+
host: string;
17+
pathname: string;
18+
method?: string;
19+
/** ACS3 canonical query string (sorted, encoded, no leading `?`). Empty for POST body-only APIs. */
20+
queryString?: string;
21+
}
22+
23+
/** Build ACS3 canonical query string from POP query parameters. */
24+
export function buildCanonicalQuery(params: Record<string, string | string[] | undefined>): string {
25+
const pairs: Array<[string, string]> = [];
26+
for (const [key, value] of Object.entries(params)) {
27+
if (value === undefined || value === "") continue;
28+
if (Array.isArray(value)) {
29+
for (let i = 0; i < value.length; i++) {
30+
const v = value[i];
31+
if (v !== "") pairs.push([`${key}.${i + 1}`, v]);
32+
}
33+
} else {
34+
pairs.push([key, value]);
35+
}
36+
}
37+
pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
38+
return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&");
39+
}
40+
41+
function encodeRFC3986(str: string): string {
42+
return encodeURIComponent(str).replace(
43+
/[!'()*]/g,
44+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
45+
);
46+
}
47+
48+
export function signTokenPlanRequest(cfg: TokenPlanAkSignConfig): Record<string, string> {
49+
const method = cfg.method ?? "POST";
50+
const now = new Date();
51+
const dateISO = now.toISOString().replace(/\.\d{3}Z$/, "Z");
52+
const nonce = randomUUID();
53+
54+
const hashedBody = sha256Hex(cfg.body);
55+
56+
const headers: Record<string, string> = {
57+
host: cfg.host,
58+
"x-acs-action": cfg.action,
59+
"x-acs-version": cfg.version,
60+
"x-acs-date": dateISO,
61+
"x-acs-signature-nonce": nonce,
62+
"x-acs-content-sha256": hashedBody,
63+
"content-type": "application/json",
64+
};
65+
66+
const signedHeaderKeys = Object.keys(headers)
67+
.filter((k) => k === "host" || k === "content-type" || k.startsWith("x-acs-"))
68+
.sort();
69+
70+
const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${headers[k]}`).join("\n") + "\n";
71+
72+
const signedHeadersStr = signedHeaderKeys.join(";");
73+
74+
const queryString = cfg.queryString ?? "";
75+
76+
const canonicalRequest = [
77+
method,
78+
cfg.pathname,
79+
queryString,
80+
canonicalHeaders,
81+
signedHeadersStr,
82+
hashedBody,
83+
].join("\n");
84+
85+
const algorithm = "ACS3-HMAC-SHA256";
86+
const hashedCanonical = sha256Hex(canonicalRequest);
87+
const stringToSign = `${algorithm}\n${hashedCanonical}`;
88+
89+
const signature = hmacSHA256Hex(cfg.accessKeySecret, stringToSign);
90+
91+
headers["authorization"] =
92+
`${algorithm} Credential=${cfg.accessKeyId},SignedHeaders=${signedHeadersStr},Signature=${signature}`;
93+
94+
return headers;
95+
}
96+
97+
function sha256Hex(data: string): string {
98+
return createHash("sha256").update(data, "utf8").digest("hex");
99+
}
100+
101+
function hmacSHA256Hex(key: string, data: string): string {
102+
return createHmac("sha256", key).update(data, "utf8").digest("hex");
103+
}

0 commit comments

Comments
 (0)