@optima-chat/dev-skills 0.8.2 → 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.
@@ -5,8 +5,9 @@ exports.validateEnvCnProd = validateEnvCnProd;
5
5
  exports.getServiceToken = getServiceToken;
6
6
  exports.callBilling = callBilling;
7
7
  exports.callSkills = callSkills;
8
+ exports.getAdminUserToken = getAdminUserToken;
9
+ exports.callUserAuthAsAdmin = callUserAuthAsAdmin;
8
10
  exports.resolveUserIdByEmail = resolveUserIdByEmail;
9
- exports.resolveUserIdByEmailAnyEnv = resolveUserIdByEmailAnyEnv;
10
11
  exports.resolveUserIdByPhone = resolveUserIdByPhone;
11
12
  exports.getUserById = getUserById;
12
13
  const child_process_1 = require("child_process");
@@ -220,6 +221,103 @@ async function callBilling(env, method, path, body) {
220
221
  async function callSkills(env, method, path, body) {
221
222
  return callService(getSkillsUrl(env), env, method, path, body);
222
223
  }
224
+ // ───── Admin-USER token (ROPC password grant) ───────────────────────────────
225
+ // Distinct from getServiceToken (M2M client_credentials): user-auth's /admin/*
226
+ // endpoints gate on get_current_admin_user — a real role=ADMIN *user* — which an
227
+ // M2M token (no user_id) can't satisfy. So ban/unban mint a password-grant token
228
+ // for the seeded admin account. Per-env ROPC client = the same public client
229
+ // generate-test-token uses; admin email/password live in Infisical
230
+ // /shared-secrets/credentials. Cached separately from the M2M tokenCache.
231
+ const adminTokenCache = {};
232
+ const ADMIN_ROPC_CLIENT = {
233
+ stage: 'commerce-cli-stage-ihbbwplz',
234
+ prod: 'commerce-cli-ecs-pro-i2r5of1h',
235
+ 'cn-prod': 'dev-skill-cli-cn-pro-acvkmcuq',
236
+ 'cn-stage': 'dev-skill-cli-cn-sta-3dvsxzdo',
237
+ };
238
+ const ADMIN_CREDS_PATH = '/shared-secrets/credentials';
239
+ async function getAdminUserToken(env) {
240
+ if (adminTokenCache[env])
241
+ return adminTokenCache[env];
242
+ let email;
243
+ let password;
244
+ if (env === 'cn-prod' || env === 'cn-stage') {
245
+ // cn Infisical (separate instance; needs INFISICAL_CN_EMAIL/PASSWORD at runtime).
246
+ const cnTok = (0, db_utils_1.getCnInfisicalToken)();
247
+ const creds = (0, db_utils_1.getCnSecrets)(cnTok, ADMIN_CREDS_PATH, false, env === 'cn-stage' ? 'staging' : 'prod');
248
+ email = creds['USER_AUTH_ADMIN_EMAIL'];
249
+ password = creds['USER_AUTH_ADMIN_PASSWORD'];
250
+ }
251
+ else {
252
+ const cfg = (0, db_utils_1.getInfisicalConfig)();
253
+ const tok = (0, db_utils_1.getInfisicalToken)(cfg);
254
+ email = (0, infisical_secrets_1.fetchInfisicalSecret)(env, ADMIN_CREDS_PATH, 'USER_AUTH_ADMIN_EMAIL', cfg, tok);
255
+ password = (0, infisical_secrets_1.fetchInfisicalSecret)(env, ADMIN_CREDS_PATH, 'USER_AUTH_ADMIN_PASSWORD', cfg, tok);
256
+ }
257
+ if (!email || !password) {
258
+ throw new Error(`admin 凭证缺失(Infisical ${ADMIN_CREDS_PATH} 的 USER_AUTH_ADMIN_EMAIL/PASSWORD,env=${env})`);
259
+ }
260
+ const clientId = ADMIN_ROPC_CLIENT[env];
261
+ const authUrl = USER_AUTH_URLS[env];
262
+ if (!clientId || !authUrl)
263
+ throw new Error(`Unknown env: ${env}`);
264
+ // fetch (not execSync curl) so the password never lands in a shell command line.
265
+ const form = new URLSearchParams({
266
+ grant_type: 'password',
267
+ username: email,
268
+ password,
269
+ client_id: clientId,
270
+ });
271
+ const res = await fetch(`${authUrl}/api/v1/oauth/token`, {
272
+ method: 'POST',
273
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
274
+ body: form,
275
+ });
276
+ const text = await res.text();
277
+ let parsed;
278
+ try {
279
+ parsed = JSON.parse(text);
280
+ }
281
+ catch {
282
+ throw new Error(`user-auth admin token endpoint returned non-JSON (${env}): ${text.slice(0, 200)}`);
283
+ }
284
+ if (!parsed.access_token) {
285
+ throw new Error(`admin-user token mint failed (${env}): ${text.slice(0, 200)}`);
286
+ }
287
+ adminTokenCache[env] = parsed.access_token;
288
+ return parsed.access_token;
289
+ }
290
+ /**
291
+ * Authenticated call to user-auth as the admin USER (role=ADMIN), for the
292
+ * /api/v1/admin/* endpoints that getServiceToken's M2M token can't reach
293
+ * (ban/unban). Mirrors callService's shape; single retry on 5xx.
294
+ */
295
+ async function callUserAuthAsAdmin(env, method, path, body) {
296
+ const authUrl = USER_AUTH_URLS[env];
297
+ if (!authUrl)
298
+ throw new Error(`Unknown env: ${env}`);
299
+ const token = await getAdminUserToken(env);
300
+ const doFetch = async () => fetch(`${authUrl}${path}`, {
301
+ method,
302
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
303
+ body: body !== undefined ? JSON.stringify(body) : undefined,
304
+ });
305
+ let res = await doFetch();
306
+ if (res.status >= 500)
307
+ res = await doFetch();
308
+ const text = await res.text();
309
+ if (!res.ok) {
310
+ throw new Error(formatServiceError(res.status, res.statusText, text));
311
+ }
312
+ let parsed;
313
+ try {
314
+ parsed = text ? JSON.parse(text) : undefined;
315
+ }
316
+ catch {
317
+ throw new Error(`user-auth admin returned non-JSON 2xx body: ${text.slice(0, 200)}`);
318
+ }
319
+ return { status: res.status, body: parsed };
320
+ }
223
321
  /**
224
322
  * Resolve a user's id by email via user-auth's internal lookup endpoint
225
323
  * (POST /api/v1/internal/users/lookup). cn-prod only: AWS envs resolve via
@@ -257,24 +355,6 @@ async function resolveUserIdByEmail(env, email) {
257
355
  console.log(`✓ Found user: ${parsed.user_id}`);
258
356
  return parsed.user_id;
259
357
  }
260
- /**
261
- * Resolve email → userId across all envs, picking the path that exists for
262
- * each cloud. AWS (stage/prod) reaches the database over the RDS SSH tunnel
263
- * (db-utils.resolveUserId). cn-prod / cn-stage have no tunnel into the Aliyun
264
- * VPC-internal RDS, so they resolve via user-auth's internal HTTP lookup
265
- * (resolveUserIdByEmail) — the cn dev-skills token carries the
266
- * internal:users:write scope that endpoint requires. Lets the entitlement
267
- * subcommands support cn the same way grant-subscription already does, without
268
- * each call site re-implementing the branch.
269
- */
270
- async function resolveUserIdByEmailAnyEnv(env, email) {
271
- if (env === 'cn-prod' || env === 'cn-stage') {
272
- return resolveUserIdByEmail(env, email);
273
- }
274
- const cfg = (0, db_utils_1.getInfisicalConfig)();
275
- const token = (0, db_utils_1.getInfisicalToken)(cfg);
276
- return (0, db_utils_1.resolveUserId)(email, env, cfg, token);
277
- }
278
358
  /**
279
359
  * Resolve a user's id by phone via user-auth's internal lookup endpoint
280
360
  * (POST /api/v1/internal/users/lookup with {phone}). Mirrors
@@ -3,18 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runGrant = runGrant;
4
4
  const billing_http_1 = require("../billing-http");
5
5
  const confirm_prompt_1 = require("../confirm-prompt");
6
+ const grant_subscription_1 = require("../grant-subscription");
6
7
  function parseArgs(argv) {
7
8
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
8
- console.log(`Usage: optima-entitlement grant --email <user> --product-key <slug> --justification "..." [options]
9
+ console.log(`Usage: optima-entitlement grant <email|phone|userId> --product-key <slug> --justification "..." [options]
9
10
 
10
11
  Required:
11
- --email <user-email> Resolved to userId via user-auth DB
12
+ <email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
12
13
  --product-key <productKey>
13
14
  --justification "..." Required by billing (400 otherwise); stored on entitlement.justification
14
15
 
15
16
  Optional:
16
- --yes Skip prod confirmation prompt (no-op on stage)
17
- --env stage|prod|cn-prod|cn-stage (default: stage)
17
+ --yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
18
+ --env stage|prod|cn-prod|cn-stage (default: stage)
18
19
 
19
20
  Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
20
21
  process.exit(0);
@@ -25,9 +26,9 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
25
26
  const next = argv[i + 1];
26
27
  switch (a) {
27
28
  case '--email':
28
- out.email = next;
29
+ out.identifier = next;
29
30
  i++;
30
- break;
31
+ break; // back-compat alias for the positional identifier
31
32
  case '--product-key':
32
33
  out.productKey = next;
33
34
  i++;
@@ -43,11 +44,16 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
43
44
  out.env = next;
44
45
  i++;
45
46
  break;
46
- default: throw new Error(`Unknown arg: ${a}`);
47
+ default:
48
+ if (a.startsWith('--'))
49
+ throw new Error(`Unknown arg: ${a}`);
50
+ if (out.identifier)
51
+ throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
52
+ out.identifier = a;
47
53
  }
48
54
  }
49
- if (!out.email)
50
- throw new Error('--email required');
55
+ if (!out.identifier)
56
+ throw new Error('target user required (<email|phone|userId> positional, or --email)');
51
57
  if (!out.productKey)
52
58
  throw new Error('--product-key required');
53
59
  if (!out.justification)
@@ -57,9 +63,11 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
57
63
  async function runGrant(argv) {
58
64
  const args = parseArgs(argv);
59
65
  (0, billing_http_1.validateEnvCnProd)(args.env);
60
- const userId = await (0, billing_http_1.resolveUserIdByEmailAnyEnv)(args.env, args.email);
61
- await (0, confirm_prompt_1.confirmIfProd)(args.env, `Action: GRANT product '${args.productKey}' to user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`, args.yes);
62
- console.log(`\n🎁 Granting ${args.productKey} to ${args.email}...`);
66
+ // Shared resolver: accepts email/phone/userId, reverse-verifies + phone-asserts
67
+ // on cn-prod, email-only via SSH tunnel on AWS (gateway#923).
68
+ const { userId } = await (0, grant_subscription_1.resolveTargetUser)(args.env, args.identifier);
69
+ await (0, confirm_prompt_1.confirmIfProd)(args.env, `Action: GRANT product '${args.productKey}' to ${args.identifier} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`, args.yes);
70
+ console.log(`\n🎁 Granting ${args.productKey} to ${args.identifier}...`);
63
71
  const res = await (0, billing_http_1.callBilling)(args.env, 'POST', '/api/billing/admin/grant-entitlement', {
64
72
  userId,
65
73
  productKey: args.productKey,
@@ -2,15 +2,16 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runList = runList;
4
4
  const billing_http_1 = require("../billing-http");
5
+ const grant_subscription_1 = require("../grant-subscription");
5
6
  function parseArgs(argv) {
6
7
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
7
- console.log(`Usage: optima-entitlement list --email <user-email> [options]
8
+ console.log(`Usage: optima-entitlement list <email|phone|userId> [options]
8
9
 
9
10
  Required:
10
- --email <user-email> Resolved to userId via user-auth DB
11
+ <email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
11
12
 
12
13
  Optional:
13
- --env stage|prod|cn-prod|cn-stage (default: stage)`);
14
+ --env stage|prod|cn-prod|cn-stage (default: stage)`);
14
15
  process.exit(0);
15
16
  }
16
17
  const out = { env: 'stage' };
@@ -19,33 +20,38 @@ Optional:
19
20
  const next = argv[i + 1];
20
21
  switch (a) {
21
22
  case '--email':
22
- out.email = next;
23
+ out.identifier = next;
23
24
  i++;
24
- break;
25
+ break; // back-compat alias for the positional identifier
25
26
  case '--env':
26
27
  out.env = next;
27
28
  i++;
28
29
  break;
29
- default: throw new Error(`Unknown arg: ${a}`);
30
+ default:
31
+ if (a.startsWith('--'))
32
+ throw new Error(`Unknown arg: ${a}`);
33
+ if (out.identifier)
34
+ throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
35
+ out.identifier = a;
30
36
  }
31
37
  }
32
- if (!out.email)
33
- throw new Error('--email required');
38
+ if (!out.identifier)
39
+ throw new Error('target user required (<email|phone|userId> positional, or --email)');
34
40
  return out;
35
41
  }
36
42
  async function runList(argv) {
37
43
  const args = parseArgs(argv);
38
44
  (0, billing_http_1.validateEnvCnProd)(args.env);
39
- const userId = await (0, billing_http_1.resolveUserIdByEmailAnyEnv)(args.env, args.email);
45
+ const { userId } = await (0, grant_subscription_1.resolveTargetUser)(args.env, args.identifier);
40
46
  const res = await (0, billing_http_1.callBilling)(args.env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
41
47
  const rows = res.body.entitlements ?? [];
42
48
  if (rows.length === 0) {
43
- console.log(`(no entitlements for ${args.email} on ${args.env})`);
49
+ console.log(`(no entitlements for ${args.identifier} on ${args.env})`);
44
50
  return;
45
51
  }
46
52
  // Newest first per spec
47
53
  rows.sort((a, b) => b.purchasedAt.localeCompare(a.purchasedAt));
48
- console.log(`${rows.length} entitlement(s) for ${args.email}:\n`);
54
+ console.log(`${rows.length} entitlement(s) for ${args.identifier}:\n`);
49
55
  console.log('id'.padEnd(38) + ' | ' + 'productKey'.padEnd(32) + ' | ' + 'status'.padEnd(9) + ' | ' + 'source'.padEnd(12) + ' | purchasedAt | refundedAt');
50
56
  console.log('-'.repeat(140));
51
57
  for (const r of rows) {
@@ -3,18 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runRevoke = runRevoke;
4
4
  const billing_http_1 = require("../billing-http");
5
5
  const confirm_prompt_1 = require("../confirm-prompt");
6
+ const grant_subscription_1 = require("../grant-subscription");
6
7
  function parseArgs(argv) {
7
8
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
8
- console.log(`Usage: optima-entitlement revoke --email <user> --product-key <slug> --reason "..." [options]
9
+ console.log(`Usage: optima-entitlement revoke <email|phone|userId> --product-key <slug> --reason "..." [options]
9
10
 
10
11
  Required:
11
- --email <user-email> Resolved to userId via user-auth DB
12
+ <email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
12
13
  --product-key <productKey>
13
14
  --reason "..." Required by billing (400 otherwise); stored on entitlement.refundReason
14
15
 
15
16
  Optional:
16
- --yes Skip prod confirmation prompt (no-op on stage)
17
- --env stage|prod|cn-prod|cn-stage (default: stage)
17
+ --yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
18
+ --env stage|prod|cn-prod|cn-stage (default: stage)
18
19
 
19
20
  Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
20
21
  error pointing to the right reversal flow.`);
@@ -26,9 +27,9 @@ error pointing to the right reversal flow.`);
26
27
  const next = argv[i + 1];
27
28
  switch (a) {
28
29
  case '--email':
29
- out.email = next;
30
+ out.identifier = next;
30
31
  i++;
31
- break;
32
+ break; // back-compat alias for the positional identifier
32
33
  case '--product-key':
33
34
  out.productKey = next;
34
35
  i++;
@@ -44,11 +45,16 @@ error pointing to the right reversal flow.`);
44
45
  out.env = next;
45
46
  i++;
46
47
  break;
47
- default: throw new Error(`Unknown arg: ${a}`);
48
+ default:
49
+ if (a.startsWith('--'))
50
+ throw new Error(`Unknown arg: ${a}`);
51
+ if (out.identifier)
52
+ throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
53
+ out.identifier = a;
48
54
  }
49
55
  }
50
- if (!out.email)
51
- throw new Error('--email required');
56
+ if (!out.identifier)
57
+ throw new Error('target user required (<email|phone|userId> positional, or --email)');
52
58
  if (!out.productKey)
53
59
  throw new Error('--product-key required');
54
60
  if (!out.reason)
@@ -60,7 +66,7 @@ const PARTNER_REFUSAL = `refusing to revoke a PARTNER-source entitlement via CLI
60
66
  async function runRevoke(argv) {
61
67
  const args = parseArgs(argv);
62
68
  (0, billing_http_1.validateEnvCnProd)(args.env);
63
- const userId = await (0, billing_http_1.resolveUserIdByEmailAnyEnv)(args.env, args.email);
69
+ const { userId } = await (0, grant_subscription_1.resolveTargetUser)(args.env, args.identifier);
64
70
  // Step 1: Fetch user's entitlements
65
71
  const listRes = await (0, billing_http_1.callBilling)(args.env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
66
72
  const all = listRes.body.entitlements ?? [];
@@ -68,10 +74,10 @@ async function runRevoke(argv) {
68
74
  const matches = all.filter((e) => e.status === 'ACTIVE' && e.productKey === args.productKey);
69
75
  // Step 3: Validate count (partial unique index enforces ≤1 ACTIVE)
70
76
  if (matches.length === 0) {
71
- throw new Error(`no active entitlement for (user=${args.email}, product=${args.productKey}) on ${args.env}`);
77
+ throw new Error(`no active entitlement for (user=${args.identifier}, product=${args.productKey}) on ${args.env}`);
72
78
  }
73
79
  if (matches.length > 1) {
74
- 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}`);
80
+ throw new Error(`unexpected: ${matches.length} ACTIVE entitlements for (user, product) — partial unique constraint should prevent this. Inspect with: optima-entitlement list ${args.identifier}`);
75
81
  }
76
82
  const target = matches[0];
77
83
  // Step 4: Validate source
@@ -81,7 +87,7 @@ async function runRevoke(argv) {
81
87
  throw new Error(PARTNER_REFUSAL);
82
88
  if (target.source !== 'ADMIN_GRANT')
83
89
  throw new Error(`unknown entitlement source: ${target.source}`);
84
- await (0, confirm_prompt_1.confirmIfProd)(args.env, `Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for user ${args.email} (userId=${userId}) on ${args.env.toUpperCase()}\nReason: ${args.reason}`, args.yes);
90
+ await (0, confirm_prompt_1.confirmIfProd)(args.env, `Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for ${args.identifier} (userId=${userId}) on ${args.env.toUpperCase()}\nReason: ${args.reason}`, args.yes);
85
91
  // Step 5: Refund
86
92
  // refundAmountCents=0 is always correct for ADMIN_GRANT (priceCents=0,
87
93
  // no upstream charge). Pass explicitly so billing's auto-compute
@@ -91,7 +97,7 @@ async function runRevoke(argv) {
91
97
  // this branch only ever runs for ADMIN_GRANT (priceCents=0) — Stripe
92
98
  // refund path in billing (admin-products.ts:296-307) is gated on
93
99
  // source=PAYMENT and won't trigger here.
94
- console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.email}...`);
100
+ console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.identifier}...`);
95
101
  const res = await (0, billing_http_1.callBilling)(args.env, 'POST', '/api/billing/admin/refund-entitlement', {
96
102
  entitlementId: target.id,
97
103
  refundReason: args.reason,
@@ -6,16 +6,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  // 改调 billing 服务态端点(grantCredits → bonus 积分)。
7
7
  // ⚠️ 语义变化:旧 wallet granted 无期限;积分 bonus 桶标准 30 天有效期。
8
8
  const crypto_1 = require("crypto");
9
- const db_utils_1 = require("./db-utils");
10
9
  const billing_http_1 = require("./billing-http");
10
+ const grant_subscription_1 = require("./grant-subscription");
11
11
  function parseArgs(args) {
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,12 +27,11 @@ 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
- const email = args[0];
34
+ const identifier = args[0];
33
35
  let amountUsd = 0;
34
36
  let description = null;
35
37
  let env = 'stage';
@@ -49,24 +51,17 @@ Examples:
49
51
  process.exit(1);
50
52
  }
51
53
  (0, billing_http_1.validateEnvCnProd)(env);
52
- return { email, amountUsd, description, env };
54
+ return { identifier, amountUsd, description, env };
53
55
  }
54
56
  async function main() {
55
- const { email, amountUsd, description, env } = parseArgs(process.argv.slice(2));
56
- console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${email} [${env.toUpperCase()}]\n`);
57
+ const { identifier, amountUsd, description, env } = parseArgs(process.argv.slice(2));
58
+ console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${identifier} [${env.toUpperCase()}]\n`);
57
59
  if (description)
58
60
  console.log(` Reason: ${description}`);
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;
62
- if (env === 'cn-prod' || env === 'cn-stage') {
63
- userId = await (0, billing_http_1.resolveUserIdByEmail)(env, email);
64
- }
65
- else {
66
- const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
67
- const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
68
- userId = await (0, db_utils_1.resolveUserId)(email, env, infisicalConfig, token);
69
- }
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 (0, grant_subscription_1.resolveTargetUser)(env, identifier);
70
65
  // 幂等键 per-invocation 生成、callBilling 的 5xx retry 复用同 body —— 「已
71
66
  // commit 但响应 5xx」场景重试不双发(billing spec R2-M3)。
72
67
  const { body } = await (0, billing_http_1.callBilling)(env, 'POST', '/api/billing/admin/grant-credits', {
@@ -76,7 +71,7 @@ async function main() {
76
71
  idempotencyKey: `dev-skills-grant:${(0, crypto_1.randomUUID)()}`,
77
72
  });
78
73
  console.log(`✓ Granted ${body.credits} credits (lot ${body.lotId})`);
79
- console.log(`\n✅ Done! ${email} received ${body.credits} bonus credits (expires in 30 days)\n`);
74
+ console.log(`\n✅ Done! ${identifier} received ${body.credits} bonus credits (expires in 30 days)\n`);
80
75
  }
81
76
  main().catch(error => {
82
77
  console.error('\n❌ Error:', error.message);
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.classifyIdentifier = classifyIdentifier;
5
5
  exports.assertPhoneMatch = assertPhoneMatch;
6
6
  exports.assertAwsEmailOnly = assertAwsEmailOnly;
7
+ exports.resolveTargetUser = resolveTargetUser;
7
8
  // P15 D8b:USD 钱包退役——原「SSH 直写 subscriptions/usd_wallets/token_quotas」
8
9
  // 作废,改调 billing 服务态端点(与用户态 /api/admin/grant-subscription 同
9
10
  // 业务体:supersede 旧授予 + 实得 credits + token quota,重试双发不双倍)。
@@ -108,19 +109,29 @@ Options:
108
109
  }
109
110
  return { identifier, plan, months, env };
110
111
  }
111
- async function main() {
112
- const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
112
+ /**
113
+ * Resolve a CLI identifier (`<email|phone|userId>`) to a userId + verified
114
+ * account identity, handling the AWS-vs-cn split. Shared by grant-subscription,
115
+ * grant-balance, the optima-entitlement subcommands, and optima-account so all
116
+ * four accept the same identifier forms (gateway#923: phone/userId on cn).
117
+ *
118
+ * - AWS (stage/prod): email-only via the RDS SSH tunnel; no internal HTTP
119
+ * reverse-verify (the AWS dev-skills token lacks internal:users:write).
120
+ * - cn-prod / cn-stage: no tunnel into the Aliyun RDS — classify → resolve via
121
+ * user-auth internal endpoints → getUserById reverse-verify → phone-assert.
122
+ * cn-stage shares cn-prod's HTTP path (same internal endpoints, cn-stage M2M
123
+ * token); the split is AWS-vs-cn, NOT prod-vs-stage.
124
+ *
125
+ * Prints a `🎯 目标账号` line in BOTH branches before returning, so a wrong
126
+ * userId is caught by eye before any destructive call (ban/grant/revoke).
127
+ */
128
+ async function resolveTargetUser(env, identifier) {
113
129
  const kind = classifyIdentifier(identifier);
114
130
  assertAwsEmailOnly(env, kind);
115
- console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${kind}) for ${months} month(s) [${env.toUpperCase()}]\n`);
116
- // identity is the verified target account to print on success. AWS keeps the
117
- // pre-change behavior (email-only, no internal HTTP reverse-verify); only
118
- // cn-prod / cn-stage run the full classify→resolve→getUserById→phone-assert防呆 path.
119
- let userId;
120
- let identity;
121
131
  if (env === 'cn-prod' || env === 'cn-stage') {
122
132
  // cn-prod / cn-stage have no SSH tunnel into the Aliyun RDS — resolve via
123
133
  // user-auth, and the dev-skills token carries internal:users:write for lookups.
134
+ let userId;
124
135
  if (kind === 'userId') {
125
136
  userId = identifier;
126
137
  }
@@ -131,25 +142,31 @@ async function main() {
131
142
  userId = await (0, billing_http_1.resolveUserIdByEmail)(env, identifier);
132
143
  }
133
144
  // Reverse-verify: fetch and loudly print the target account identity
134
- // before granting, so a wrong userId is caught by eye (gateway#923).
145
+ // before any mutation, so a wrong userId is caught by eye (gateway#923).
135
146
  const acct = await (0, billing_http_1.getUserById)(env, userId);
136
147
  console.log(`🎯 目标账号: userId=${userId} 手机=${acct.phone || '(无)'} email=${acct.email || '(无)'} 当前plan=${acct.current_plan || '?'}`);
137
148
  // Hard assertion: a phone-input grant must land on an account whose phone
138
- // matches. Runs BEFORE callBilling — a mismatch aborts without granting.
149
+ // matches. Runs BEFORE the caller's mutation — a mismatch aborts.
139
150
  if (kind === 'phone') {
140
151
  assertPhoneMatch(identifier, acct.phone);
141
152
  }
142
- identity = { phone: acct.phone, email: acct.email };
143
- }
144
- else {
145
- // AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No
146
- // internal HTTP reverse-verify (token lacks internal:users:write → 403).
147
- const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
148
- const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
149
- userId = await (0, db_utils_1.resolveUserId)(identifier, env, infisicalConfig, token);
150
- console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
151
- identity = { phone: null, email: identifier };
153
+ return { userId, kind, identity: { phone: acct.phone, email: acct.email } };
152
154
  }
155
+ // AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No internal
156
+ // HTTP reverse-verify (token lacks internal:users:write → 403), but still
157
+ // echo the resolved account so destructive ops have a confirmation line (R1).
158
+ const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
159
+ const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
160
+ const userId = await (0, db_utils_1.resolveUserId)(identifier, env, infisicalConfig, token);
161
+ console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
162
+ return { userId, kind, identity: { phone: null, email: identifier } };
163
+ }
164
+ async function main() {
165
+ const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
166
+ console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${classifyIdentifier(identifier)}) for ${months} month(s) [${env.toUpperCase()}]\n`);
167
+ // Shared resolver: classify → resolve → reverse-verify echo → phone-assert
168
+ // (cn-prod/cn-stage), or email-only via SSH tunnel (AWS). See resolveTargetUser.
169
+ const { userId, identity } = await resolveTargetUser(env, identifier);
153
170
  const { body } = await (0, billing_http_1.callBilling)(env, 'POST', '/api/billing/admin/grant-subscription', { userId, planId: plan, months });
154
171
  console.log(`✓ Subscription ${body.subscriptionId} (${body.planId})`);
155
172
  console.log(`✓ Credits: ${body.credits.toLocaleString()} (expires ${body.expiresAt})`);