@optima-chat/dev-skills 0.8.1 → 0.9.0

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.
@@ -255,6 +255,111 @@ export async function callSkills<T = unknown>(
255
255
  return callService<T>(getSkillsUrl(env), env, method, path, body);
256
256
  }
257
257
 
258
+ // ───── Admin-USER token (ROPC password grant) ───────────────────────────────
259
+ // Distinct from getServiceToken (M2M client_credentials): user-auth's /admin/*
260
+ // endpoints gate on get_current_admin_user — a real role=ADMIN *user* — which an
261
+ // M2M token (no user_id) can't satisfy. So ban/unban mint a password-grant token
262
+ // for the seeded admin account. Per-env ROPC client = the same public client
263
+ // generate-test-token uses; admin email/password live in Infisical
264
+ // /shared-secrets/credentials. Cached separately from the M2M tokenCache.
265
+ const adminTokenCache: Record<string, string> = {};
266
+
267
+ const ADMIN_ROPC_CLIENT: Record<string, string> = {
268
+ stage: 'commerce-cli-stage-ihbbwplz',
269
+ prod: 'commerce-cli-ecs-pro-i2r5of1h',
270
+ 'cn-prod': 'dev-skill-cli-cn-pro-acvkmcuq',
271
+ 'cn-stage': 'dev-skill-cli-cn-sta-3dvsxzdo',
272
+ };
273
+
274
+ const ADMIN_CREDS_PATH = '/shared-secrets/credentials';
275
+
276
+ export async function getAdminUserToken(env: string): Promise<string> {
277
+ if (adminTokenCache[env]) return adminTokenCache[env];
278
+
279
+ let email: string;
280
+ let password: string;
281
+ if (env === 'cn-prod' || env === 'cn-stage') {
282
+ // cn Infisical (separate instance; needs INFISICAL_CN_EMAIL/PASSWORD at runtime).
283
+ const cnTok = getCnInfisicalToken();
284
+ const creds = getCnSecrets(cnTok, ADMIN_CREDS_PATH, false, env === 'cn-stage' ? 'staging' : 'prod');
285
+ email = creds['USER_AUTH_ADMIN_EMAIL'];
286
+ password = creds['USER_AUTH_ADMIN_PASSWORD'];
287
+ } else {
288
+ const cfg = getInfisicalConfig();
289
+ const tok = getInfisicalToken(cfg);
290
+ email = fetchInfisicalSecret(env, ADMIN_CREDS_PATH, 'USER_AUTH_ADMIN_EMAIL', cfg, tok);
291
+ password = fetchInfisicalSecret(env, ADMIN_CREDS_PATH, 'USER_AUTH_ADMIN_PASSWORD', cfg, tok);
292
+ }
293
+ if (!email || !password) {
294
+ throw new Error(`admin 凭证缺失(Infisical ${ADMIN_CREDS_PATH} 的 USER_AUTH_ADMIN_EMAIL/PASSWORD,env=${env})`);
295
+ }
296
+
297
+ const clientId = ADMIN_ROPC_CLIENT[env];
298
+ const authUrl = USER_AUTH_URLS[env];
299
+ if (!clientId || !authUrl) throw new Error(`Unknown env: ${env}`);
300
+
301
+ // fetch (not execSync curl) so the password never lands in a shell command line.
302
+ const form = new URLSearchParams({
303
+ grant_type: 'password',
304
+ username: email,
305
+ password,
306
+ client_id: clientId,
307
+ });
308
+ const res = await fetch(`${authUrl}/api/v1/oauth/token`, {
309
+ method: 'POST',
310
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
311
+ body: form,
312
+ });
313
+ const text = await res.text();
314
+ let parsed: { access_token?: string };
315
+ try {
316
+ parsed = JSON.parse(text);
317
+ } catch {
318
+ throw new Error(`user-auth admin token endpoint returned non-JSON (${env}): ${text.slice(0, 200)}`);
319
+ }
320
+ if (!parsed.access_token) {
321
+ throw new Error(`admin-user token mint failed (${env}): ${text.slice(0, 200)}`);
322
+ }
323
+ adminTokenCache[env] = parsed.access_token;
324
+ return parsed.access_token;
325
+ }
326
+
327
+ /**
328
+ * Authenticated call to user-auth as the admin USER (role=ADMIN), for the
329
+ * /api/v1/admin/* endpoints that getServiceToken's M2M token can't reach
330
+ * (ban/unban). Mirrors callService's shape; single retry on 5xx.
331
+ */
332
+ export async function callUserAuthAsAdmin<T = unknown>(
333
+ env: string,
334
+ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
335
+ path: string,
336
+ body?: object,
337
+ ): Promise<ServiceResponse<T>> {
338
+ const authUrl = USER_AUTH_URLS[env];
339
+ if (!authUrl) throw new Error(`Unknown env: ${env}`);
340
+ const token = await getAdminUserToken(env);
341
+
342
+ const doFetch = async () => fetch(`${authUrl}${path}`, {
343
+ method,
344
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
345
+ body: body !== undefined ? JSON.stringify(body) : undefined,
346
+ });
347
+
348
+ let res = await doFetch();
349
+ if (res.status >= 500) res = await doFetch();
350
+ const text = await res.text();
351
+ if (!res.ok) {
352
+ throw new Error(formatServiceError(res.status, res.statusText, text));
353
+ }
354
+ let parsed: T;
355
+ try {
356
+ parsed = text ? (JSON.parse(text) as T) : (undefined as unknown as T);
357
+ } catch {
358
+ throw new Error(`user-auth admin returned non-JSON 2xx body: ${text.slice(0, 200)}`);
359
+ }
360
+ return { status: res.status, body: parsed };
361
+ }
362
+
258
363
  /**
259
364
  * Resolve a user's id by email via user-auth's internal lookup endpoint
260
365
  * (POST /api/v1/internal/users/lookup). cn-prod only: AWS envs resolve via
@@ -292,25 +397,6 @@ export async function resolveUserIdByEmail(env: string, email: string): Promise<
292
397
  return parsed.user_id;
293
398
  }
294
399
 
295
- /**
296
- * Resolve email → userId across all envs, picking the path that exists for
297
- * each cloud. AWS (stage/prod) reaches the database over the RDS SSH tunnel
298
- * (db-utils.resolveUserId). cn-prod / cn-stage have no tunnel into the Aliyun
299
- * VPC-internal RDS, so they resolve via user-auth's internal HTTP lookup
300
- * (resolveUserIdByEmail) — the cn dev-skills token carries the
301
- * internal:users:write scope that endpoint requires. Lets the entitlement
302
- * subcommands support cn the same way grant-subscription already does, without
303
- * each call site re-implementing the branch.
304
- */
305
- export async function resolveUserIdByEmailAnyEnv(env: string, email: string): Promise<string> {
306
- if (env === 'cn-prod' || env === 'cn-stage') {
307
- return resolveUserIdByEmail(env, email);
308
- }
309
- const cfg = getInfisicalConfig();
310
- const token = getInfisicalToken(cfg);
311
- return resolveUserId(email, env, cfg, token);
312
- }
313
-
314
400
  /**
315
401
  * Resolve a user's id by phone via user-auth's internal lookup endpoint
316
402
  * (POST /api/v1/internal/users/lookup with {phone}). Mirrors
@@ -1,8 +1,9 @@
1
- import { callBilling, validateEnvCnProd, resolveUserIdByEmailAnyEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
+ import { resolveTargetUser } from '../grant-subscription';
3
4
 
4
5
  interface GrantArgs {
5
- email: string;
6
+ identifier: string;
6
7
  productKey: string;
7
8
  justification: string;
8
9
  yes: boolean;
@@ -11,16 +12,16 @@ interface GrantArgs {
11
12
 
12
13
  function parseArgs(argv: string[]): GrantArgs {
13
14
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
14
- console.log(`Usage: optima-entitlement grant --email <user> --product-key <slug> --justification "..." [options]
15
+ console.log(`Usage: optima-entitlement grant <email|phone|userId> --product-key <slug> --justification "..." [options]
15
16
 
16
17
  Required:
17
- --email <user-email> Resolved to userId via user-auth DB
18
+ <email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
18
19
  --product-key <productKey>
19
20
  --justification "..." Required by billing (400 otherwise); stored on entitlement.justification
20
21
 
21
22
  Optional:
22
- --yes Skip prod confirmation prompt (no-op on stage)
23
- --env stage|prod|cn-prod|cn-stage (default: stage)
23
+ --yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
24
+ --env stage|prod|cn-prod|cn-stage (default: stage)
24
25
 
25
26
  Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
26
27
  process.exit(0);
@@ -30,15 +31,18 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
30
31
  const a = argv[i];
31
32
  const next = argv[i + 1];
32
33
  switch (a) {
33
- case '--email': out.email = next; i++; break;
34
+ case '--email': out.identifier = next; i++; break; // back-compat alias for the positional identifier
34
35
  case '--product-key': out.productKey = next; i++; break;
35
36
  case '--justification': out.justification = next; i++; break;
36
37
  case '--yes': out.yes = true; break;
37
38
  case '--env': out.env = next; i++; break;
38
- default: throw new Error(`Unknown arg: ${a}`);
39
+ default:
40
+ if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
41
+ if (out.identifier) throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
42
+ out.identifier = a;
39
43
  }
40
44
  }
41
- if (!out.email) throw new Error('--email required');
45
+ if (!out.identifier) throw new Error('target user required (<email|phone|userId> positional, or --email)');
42
46
  if (!out.productKey) throw new Error('--product-key required');
43
47
  if (!out.justification) throw new Error('--justification required (billing returns 400 otherwise)');
44
48
  return out as GrantArgs;
@@ -48,15 +52,17 @@ export async function runGrant(argv: string[]): Promise<void> {
48
52
  const args = parseArgs(argv);
49
53
  validateEnvCnProd(args.env);
50
54
 
51
- const userId = await resolveUserIdByEmailAnyEnv(args.env, args.email);
55
+ // Shared resolver: accepts email/phone/userId, reverse-verifies + phone-asserts
56
+ // on cn-prod, email-only via SSH tunnel on AWS (gateway#923).
57
+ const { userId } = await resolveTargetUser(args.env, args.identifier);
52
58
 
53
59
  await confirmIfProd(
54
60
  args.env,
55
- `Action: GRANT product '${args.productKey}' to user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`,
61
+ `Action: GRANT product '${args.productKey}' to ${args.identifier} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`,
56
62
  args.yes,
57
63
  );
58
64
 
59
- console.log(`\n🎁 Granting ${args.productKey} to ${args.email}...`);
65
+ console.log(`\n🎁 Granting ${args.productKey} to ${args.identifier}...`);
60
66
  const res = await callBilling(args.env, 'POST', '/api/billing/admin/grant-entitlement', {
61
67
  userId,
62
68
  productKey: args.productKey,
@@ -1,19 +1,20 @@
1
- import { callBilling, validateEnvCnProd, resolveUserIdByEmailAnyEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd } from '../billing-http';
2
+ import { resolveTargetUser } from '../grant-subscription';
2
3
 
3
4
  interface ListArgs {
4
- email: string;
5
+ identifier: string;
5
6
  env: string;
6
7
  }
7
8
 
8
9
  function parseArgs(argv: string[]): ListArgs {
9
10
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
10
- console.log(`Usage: optima-entitlement list --email <user-email> [options]
11
+ console.log(`Usage: optima-entitlement list <email|phone|userId> [options]
11
12
 
12
13
  Required:
13
- --email <user-email> Resolved to userId via user-auth DB
14
+ <email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
14
15
 
15
16
  Optional:
16
- --env stage|prod|cn-prod|cn-stage (default: stage)`);
17
+ --env stage|prod|cn-prod|cn-stage (default: stage)`);
17
18
  process.exit(0);
18
19
  }
19
20
  const out: Partial<ListArgs> = { env: 'stage' };
@@ -21,12 +22,15 @@ Optional:
21
22
  const a = argv[i];
22
23
  const next = argv[i + 1];
23
24
  switch (a) {
24
- case '--email': out.email = next; i++; break;
25
+ case '--email': out.identifier = next; i++; break; // back-compat alias for the positional identifier
25
26
  case '--env': out.env = next; i++; break;
26
- default: throw new Error(`Unknown arg: ${a}`);
27
+ default:
28
+ if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
29
+ if (out.identifier) throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
30
+ out.identifier = a;
27
31
  }
28
32
  }
29
- if (!out.email) throw new Error('--email required');
33
+ if (!out.identifier) throw new Error('target user required (<email|phone|userId> positional, or --email)');
30
34
  return out as ListArgs;
31
35
  }
32
36
 
@@ -43,7 +47,7 @@ export async function runList(argv: string[]): Promise<void> {
43
47
  const args = parseArgs(argv);
44
48
  validateEnvCnProd(args.env);
45
49
 
46
- const userId = await resolveUserIdByEmailAnyEnv(args.env, args.email);
50
+ const { userId } = await resolveTargetUser(args.env, args.identifier);
47
51
 
48
52
  const res = await callBilling<{ entitlements: EntitlementRow[] }>(
49
53
  args.env,
@@ -53,12 +57,12 @@ export async function runList(argv: string[]): Promise<void> {
53
57
 
54
58
  const rows = res.body.entitlements ?? [];
55
59
  if (rows.length === 0) {
56
- console.log(`(no entitlements for ${args.email} on ${args.env})`);
60
+ console.log(`(no entitlements for ${args.identifier} on ${args.env})`);
57
61
  return;
58
62
  }
59
63
  // Newest first per spec
60
64
  rows.sort((a, b) => b.purchasedAt.localeCompare(a.purchasedAt));
61
- console.log(`${rows.length} entitlement(s) for ${args.email}:\n`);
65
+ console.log(`${rows.length} entitlement(s) for ${args.identifier}:\n`);
62
66
  console.log('id'.padEnd(38) + ' | ' + 'productKey'.padEnd(32) + ' | ' + 'status'.padEnd(9) + ' | ' + 'source'.padEnd(12) + ' | purchasedAt | refundedAt');
63
67
  console.log('-'.repeat(140));
64
68
  for (const r of rows) {
@@ -1,8 +1,9 @@
1
- import { callBilling, validateEnvCnProd, resolveUserIdByEmailAnyEnv } from '../billing-http';
1
+ import { callBilling, validateEnvCnProd } from '../billing-http';
2
2
  import { confirmIfProd } from '../confirm-prompt';
3
+ import { resolveTargetUser } from '../grant-subscription';
3
4
 
4
5
  interface RevokeArgs {
5
- email: string;
6
+ identifier: string;
6
7
  productKey: string;
7
8
  reason: string;
8
9
  yes: boolean;
@@ -18,16 +19,16 @@ interface EntitlementRow {
18
19
 
19
20
  function parseArgs(argv: string[]): RevokeArgs {
20
21
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
21
- console.log(`Usage: optima-entitlement revoke --email <user> --product-key <slug> --reason "..." [options]
22
+ console.log(`Usage: optima-entitlement revoke <email|phone|userId> --product-key <slug> --reason "..." [options]
22
23
 
23
24
  Required:
24
- --email <user-email> Resolved to userId via user-auth DB
25
+ <email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
25
26
  --product-key <productKey>
26
27
  --reason "..." Required by billing (400 otherwise); stored on entitlement.refundReason
27
28
 
28
29
  Optional:
29
- --yes Skip prod confirmation prompt (no-op on stage)
30
- --env stage|prod|cn-prod|cn-stage (default: stage)
30
+ --yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
31
+ --env stage|prod|cn-prod|cn-stage (default: stage)
31
32
 
32
33
  Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
33
34
  error pointing to the right reversal flow.`);
@@ -38,15 +39,18 @@ error pointing to the right reversal flow.`);
38
39
  const a = argv[i];
39
40
  const next = argv[i + 1];
40
41
  switch (a) {
41
- case '--email': out.email = next; i++; break;
42
+ case '--email': out.identifier = next; i++; break; // back-compat alias for the positional identifier
42
43
  case '--product-key': out.productKey = next; i++; break;
43
44
  case '--reason': out.reason = next; i++; break;
44
45
  case '--yes': out.yes = true; break;
45
46
  case '--env': out.env = next; i++; break;
46
- default: throw new Error(`Unknown arg: ${a}`);
47
+ default:
48
+ if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
49
+ if (out.identifier) throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
50
+ out.identifier = a;
47
51
  }
48
52
  }
49
- if (!out.email) throw new Error('--email required');
53
+ if (!out.identifier) throw new Error('target user required (<email|phone|userId> positional, or --email)');
50
54
  if (!out.productKey) throw new Error('--product-key required');
51
55
  if (!out.reason) throw new Error('--reason required (billing returns 400 otherwise)');
52
56
  return out as RevokeArgs;
@@ -60,7 +64,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
60
64
  const args = parseArgs(argv);
61
65
  validateEnvCnProd(args.env);
62
66
 
63
- const userId = await resolveUserIdByEmailAnyEnv(args.env, args.email);
67
+ const { userId } = await resolveTargetUser(args.env, args.identifier);
64
68
 
65
69
  // Step 1: Fetch user's entitlements
66
70
  const listRes = await callBilling<{ entitlements: EntitlementRow[] }>(
@@ -75,10 +79,10 @@ export async function runRevoke(argv: string[]): Promise<void> {
75
79
 
76
80
  // Step 3: Validate count (partial unique index enforces ≤1 ACTIVE)
77
81
  if (matches.length === 0) {
78
- throw new Error(`no active entitlement for (user=${args.email}, product=${args.productKey}) on ${args.env}`);
82
+ throw new Error(`no active entitlement for (user=${args.identifier}, product=${args.productKey}) on ${args.env}`);
79
83
  }
80
84
  if (matches.length > 1) {
81
- throw new Error(`unexpected: ${matches.length} ACTIVE entitlements for (user, product) — partial unique constraint should prevent this. Inspect with: optima-entitlement list --email ${args.email}`);
85
+ throw new Error(`unexpected: ${matches.length} ACTIVE entitlements for (user, product) — partial unique constraint should prevent this. Inspect with: optima-entitlement list ${args.identifier}`);
82
86
  }
83
87
  const target = matches[0];
84
88
 
@@ -89,7 +93,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
89
93
 
90
94
  await confirmIfProd(
91
95
  args.env,
92
- `Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nReason: ${args.reason}`,
96
+ `Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for ${args.identifier} (userId=${userId}) on ${args.env.toUpperCase()}\nReason: ${args.reason}`,
93
97
  args.yes,
94
98
  );
95
99
 
@@ -102,7 +106,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
102
106
  // this branch only ever runs for ADMIN_GRANT (priceCents=0) — Stripe
103
107
  // refund path in billing (admin-products.ts:296-307) is gated on
104
108
  // source=PAYMENT and won't trigger here.
105
- console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.email}...`);
109
+ console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.identifier}...`);
106
110
  const res = await callBilling(args.env, 'POST', '/api/billing/admin/refund-entitlement', {
107
111
  entitlementId: target.id,
108
112
  refundReason: args.reason,
@@ -5,17 +5,20 @@
5
5
  // 改调 billing 服务态端点(grantCredits → bonus 积分)。
6
6
  // ⚠️ 语义变化:旧 wallet granted 无期限;积分 bonus 桶标准 30 天有效期。
7
7
  import { randomUUID } from 'crypto';
8
- import { getInfisicalConfig, getInfisicalToken, resolveUserId } from './db-utils';
9
- import { callBilling, resolveUserIdByEmail, validateEnvCnProd } from './billing-http';
8
+ import { callBilling, validateEnvCnProd } from './billing-http';
9
+ import { resolveTargetUser } from './grant-subscription';
10
10
 
11
- function parseArgs(args: string[]): { email: string; amountUsd: number; description: string | null; env: string } {
11
+ function parseArgs(args: string[]): { identifier: string; amountUsd: number; description: string | null; env: string } {
12
12
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
13
- console.log(`Usage: optima-grant-balance <email> --amount <usd> [options]
13
+ console.log(`Usage: optima-grant-balance <email|phone|userId> --amount <usd> [options]
14
14
 
15
15
  Grant credits to a user (bonus bucket, expires in 30 days).
16
16
  Used for promotional grants, compensation, referral rewards, etc.
17
17
  $1 = 700 credits (P15 unified ledger; the USD wallet is retired).
18
18
 
19
+ Target user: <email|phone|userId> (positional). phone/userId only on
20
+ cn-prod / cn-stage; AWS stage/prod resolve email only.
21
+
19
22
  Options:
20
23
  --amount <usd> USD amount to grant (required, e.g. 5 for $5.00 = 3500 credits)
21
24
  --description <text> Description for audit trail (optional)
@@ -24,13 +27,12 @@ Options:
24
27
 
25
28
  Examples:
26
29
  optima-grant-balance user@example.com --amount 5 --env prod
27
- optima-grant-balance user@example.com --amount 10 --description "Service outage compensation"
28
- optima-grant-balance user@example.com --amount 1 --env cn-prod # ¥-priced env, still USD input ($1 = 700 credits)
30
+ optima-grant-balance 18898654855 --amount 1 --env cn-prod # 手机号(cn 用户多为手机号注册)
29
31
  optima-grant-balance user@example.com --amount 1 --env cn-stage # 阿里云预发`);
30
32
  process.exit(0);
31
33
  }
32
34
 
33
- const email = args[0];
35
+ const identifier = args[0];
34
36
  let amountUsd = 0;
35
37
  let description: string | null = null;
36
38
  let env = 'stage';
@@ -47,25 +49,19 @@ Examples:
47
49
  }
48
50
  validateEnvCnProd(env);
49
51
 
50
- return { email, amountUsd, description, env };
52
+ return { identifier, amountUsd, description, env };
51
53
  }
52
54
 
53
55
  async function main() {
54
- const { email, amountUsd, description, env } = parseArgs(process.argv.slice(2));
56
+ const { identifier, amountUsd, description, env } = parseArgs(process.argv.slice(2));
55
57
 
56
- console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${email} [${env.toUpperCase()}]\n`);
58
+ console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${identifier} [${env.toUpperCase()}]\n`);
57
59
  if (description) console.log(` Reason: ${description}`);
58
60
 
59
- // cn-prod / cn-stage have no SSH tunnel into the Aliyun RDS — resolve via
60
- // user-auth's internal lookup API instead of the direct SQL path.
61
- let userId: string;
62
- if (env === 'cn-prod' || env === 'cn-stage') {
63
- userId = await resolveUserIdByEmail(env, email);
64
- } else {
65
- const infisicalConfig = getInfisicalConfig();
66
- const token = getInfisicalToken(infisicalConfig);
67
- userId = await resolveUserId(email, env, infisicalConfig, token);
68
- }
61
+ // Shared resolver: classify→resolve→reverse-verify echo→phone-assert on cn
62
+ // (so phone/userId works for cn's phone-registered users, gateway#923);
63
+ // email-only via the RDS SSH tunnel on AWS.
64
+ const { userId } = await resolveTargetUser(env, identifier);
69
65
 
70
66
  // 幂等键 per-invocation 生成、callBilling 的 5xx retry 复用同 body —— 「已
71
67
  // commit 但响应 5xx」场景重试不双发(billing spec R2-M3)。
@@ -80,7 +76,7 @@ async function main() {
80
76
  );
81
77
 
82
78
  console.log(`✓ Granted ${body.credits} credits (lot ${body.lotId})`);
83
- console.log(`\n✅ Done! ${email} received ${body.credits} bonus credits (expires in 30 days)\n`);
79
+ console.log(`\n✅ Done! ${identifier} received ${body.credits} bonus credits (expires in 30 days)\n`);
84
80
  }
85
81
 
86
82
  main().catch(error => {
@@ -111,22 +111,37 @@ Options:
111
111
  return { identifier, plan, months, env };
112
112
  }
113
113
 
114
- async function main() {
115
- const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
116
-
114
+ /**
115
+ * Resolve a CLI identifier (`<email|phone|userId>`) to a userId + verified
116
+ * account identity, handling the AWS-vs-cn split. Shared by grant-subscription,
117
+ * grant-balance, the optima-entitlement subcommands, and optima-account so all
118
+ * four accept the same identifier forms (gateway#923: phone/userId on cn).
119
+ *
120
+ * - AWS (stage/prod): email-only via the RDS SSH tunnel; no internal HTTP
121
+ * reverse-verify (the AWS dev-skills token lacks internal:users:write).
122
+ * - cn-prod / cn-stage: no tunnel into the Aliyun RDS — classify → resolve via
123
+ * user-auth internal endpoints → getUserById reverse-verify → phone-assert.
124
+ * cn-stage shares cn-prod's HTTP path (same internal endpoints, cn-stage M2M
125
+ * token); the split is AWS-vs-cn, NOT prod-vs-stage.
126
+ *
127
+ * Prints a `🎯 目标账号` line in BOTH branches before returning, so a wrong
128
+ * userId is caught by eye before any destructive call (ban/grant/revoke).
129
+ */
130
+ export async function resolveTargetUser(
131
+ env: string,
132
+ identifier: string,
133
+ ): Promise<{
134
+ userId: string;
135
+ kind: 'email' | 'phone' | 'userId';
136
+ identity: { phone: string | null; email: string | null };
137
+ }> {
117
138
  const kind = classifyIdentifier(identifier);
118
139
  assertAwsEmailOnly(env, kind);
119
- console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${kind}) for ${months} month(s) [${env.toUpperCase()}]\n`);
120
-
121
- // identity is the verified target account to print on success. AWS keeps the
122
- // pre-change behavior (email-only, no internal HTTP reverse-verify); only
123
- // cn-prod / cn-stage run the full classify→resolve→getUserById→phone-assert防呆 path.
124
- let userId: string;
125
- let identity: { phone: string | null; email: string | null };
126
140
 
127
141
  if (env === 'cn-prod' || env === 'cn-stage') {
128
142
  // cn-prod / cn-stage have no SSH tunnel into the Aliyun RDS — resolve via
129
143
  // user-auth, and the dev-skills token carries internal:users:write for lookups.
144
+ let userId: string;
130
145
  if (kind === 'userId') {
131
146
  userId = identifier;
132
147
  } else if (kind === 'phone') {
@@ -136,28 +151,39 @@ async function main() {
136
151
  }
137
152
 
138
153
  // Reverse-verify: fetch and loudly print the target account identity
139
- // before granting, so a wrong userId is caught by eye (gateway#923).
154
+ // before any mutation, so a wrong userId is caught by eye (gateway#923).
140
155
  const acct = await getUserById(env, userId);
141
156
  console.log(
142
157
  `🎯 目标账号: userId=${userId} 手机=${acct.phone || '(无)'} email=${acct.email || '(无)'} 当前plan=${acct.current_plan || '?'}`,
143
158
  );
144
159
 
145
160
  // Hard assertion: a phone-input grant must land on an account whose phone
146
- // matches. Runs BEFORE callBilling — a mismatch aborts without granting.
161
+ // matches. Runs BEFORE the caller's mutation — a mismatch aborts.
147
162
  if (kind === 'phone') {
148
163
  assertPhoneMatch(identifier, acct.phone);
149
164
  }
150
- identity = { phone: acct.phone, email: acct.email };
151
- } else {
152
- // AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No
153
- // internal HTTP reverse-verify (token lacks internal:users:write → 403).
154
- const infisicalConfig = getInfisicalConfig();
155
- const token = getInfisicalToken(infisicalConfig);
156
- userId = await resolveUserId(identifier, env, infisicalConfig, token);
157
- console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
158
- identity = { phone: null, email: identifier };
165
+ return { userId, kind, identity: { phone: acct.phone, email: acct.email } };
159
166
  }
160
167
 
168
+ // AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No internal
169
+ // HTTP reverse-verify (token lacks internal:users:write → 403), but still
170
+ // echo the resolved account so destructive ops have a confirmation line (R1).
171
+ const infisicalConfig = getInfisicalConfig();
172
+ const token = getInfisicalToken(infisicalConfig);
173
+ const userId = await resolveUserId(identifier, env, infisicalConfig, token);
174
+ console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
175
+ return { userId, kind, identity: { phone: null, email: identifier } };
176
+ }
177
+
178
+ async function main() {
179
+ const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
180
+
181
+ console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${classifyIdentifier(identifier)}) for ${months} month(s) [${env.toUpperCase()}]\n`);
182
+
183
+ // Shared resolver: classify → resolve → reverse-verify echo → phone-assert
184
+ // (cn-prod/cn-stage), or email-only via SSH tunnel (AWS). See resolveTargetUser.
185
+ const { userId, identity } = await resolveTargetUser(env, identifier);
186
+
161
187
  const { body } = await callBilling<{
162
188
  success: boolean;
163
189
  subscriptionId: string;