@optima-chat/dev-skills 0.8.2 → 0.9.1

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.
@@ -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 => {
@@ -64,15 +64,16 @@ export function assertAwsEmailOnly(env: string, kind: 'email' | 'phone' | 'userI
64
64
  }
65
65
  }
66
66
 
67
- // cn-prod sells the CNY-priced -cn plans (P12); the bare USD plan ids also
68
- // exist in the cn DB, so a per-env whitelist (not billing-side validation)
69
- // is what prevents accidentally granting a USD-priced plan to a CN user.
67
+ // All envs use canonical plan ids (no market suffix). billing canonicalized
68
+ // plan ids — dropped the `-cn` suffix; market is now carried by deployment-level
69
+ // BILLING_MARKET + the plan's `currency` column, not the id (optima-billing#181).
70
+ // cn envs additionally have the CN-only `free` tier. Per-env whitelist guards
71
+ // against granting a plan absent in that env.
70
72
  const PLANS_BY_ENV: Record<string, string[]> = {
71
73
  stage: ['trial', 'starter', 'pro', 'enterprise'],
72
74
  prod: ['trial', 'starter', 'pro', 'enterprise'],
73
- 'cn-prod': ['trial', 'starter-cn', 'pro-cn', 'enterprise-cn'],
74
- // cn-stage 卖同一套 CNY -cn plan(billing plan 目录与 cn-prod 一致)
75
- 'cn-stage': ['trial', 'starter-cn', 'pro-cn', 'enterprise-cn'],
75
+ 'cn-prod': ['trial', 'starter', 'pro', 'enterprise', 'free'],
76
+ 'cn-stage': ['trial', 'starter', 'pro', 'enterprise', 'free'],
76
77
  };
77
78
 
78
79
  function parseArgs(args: string[]): { identifier: string; plan: string; months: number; env: string } {
@@ -81,7 +82,8 @@ function parseArgs(args: string[]): { identifier: string; plan: string; months:
81
82
 
82
83
  Options:
83
84
  --plan <id> Plan: trial, starter, pro, enterprise (default: pro)
84
- cn-prod/cn-stage plans: trial, starter-cn, pro-cn, enterprise-cn (default: pro-cn)
85
+ cn-prod/cn-stage additionally allow: free
86
+ (legacy *-cn ids are accepted and normalized to canonical)
85
87
  --months <n> Duration in months (default: 1)
86
88
  --env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
87
89
  -h, --help Show this help`);
@@ -100,7 +102,10 @@ Options:
100
102
  }
101
103
 
102
104
  validateEnvCnProd(env);
103
- plan = plan ?? (env === 'cn-prod' || env === 'cn-stage' ? 'pro-cn' : 'pro');
105
+ // billing canonicalized plan ids (dropped the -cn suffix, optima-billing#181).
106
+ // Default to canonical `pro`; accept legacy `*-cn` input (operator muscle
107
+ // memory) and normalize so it still resolves after the -cn rows are deleted.
108
+ plan = (plan ?? 'pro').replace(/-cn$/, '');
104
109
  const allowed = PLANS_BY_ENV[env];
105
110
  if (!allowed.includes(plan)) {
106
111
  console.error(`Unknown plan for ${env}: ${plan}. Available: ${allowed.join(', ')}`);
@@ -111,22 +116,37 @@ Options:
111
116
  return { identifier, plan, months, env };
112
117
  }
113
118
 
114
- async function main() {
115
- const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
116
-
119
+ /**
120
+ * Resolve a CLI identifier (`<email|phone|userId>`) to a userId + verified
121
+ * account identity, handling the AWS-vs-cn split. Shared by grant-subscription,
122
+ * grant-balance, the optima-entitlement subcommands, and optima-account so all
123
+ * four accept the same identifier forms (gateway#923: phone/userId on cn).
124
+ *
125
+ * - AWS (stage/prod): email-only via the RDS SSH tunnel; no internal HTTP
126
+ * reverse-verify (the AWS dev-skills token lacks internal:users:write).
127
+ * - cn-prod / cn-stage: no tunnel into the Aliyun RDS — classify → resolve via
128
+ * user-auth internal endpoints → getUserById reverse-verify → phone-assert.
129
+ * cn-stage shares cn-prod's HTTP path (same internal endpoints, cn-stage M2M
130
+ * token); the split is AWS-vs-cn, NOT prod-vs-stage.
131
+ *
132
+ * Prints a `🎯 目标账号` line in BOTH branches before returning, so a wrong
133
+ * userId is caught by eye before any destructive call (ban/grant/revoke).
134
+ */
135
+ export async function resolveTargetUser(
136
+ env: string,
137
+ identifier: string,
138
+ ): Promise<{
139
+ userId: string;
140
+ kind: 'email' | 'phone' | 'userId';
141
+ identity: { phone: string | null; email: string | null };
142
+ }> {
117
143
  const kind = classifyIdentifier(identifier);
118
144
  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
145
 
127
146
  if (env === 'cn-prod' || env === 'cn-stage') {
128
147
  // cn-prod / cn-stage have no SSH tunnel into the Aliyun RDS — resolve via
129
148
  // user-auth, and the dev-skills token carries internal:users:write for lookups.
149
+ let userId: string;
130
150
  if (kind === 'userId') {
131
151
  userId = identifier;
132
152
  } else if (kind === 'phone') {
@@ -136,28 +156,39 @@ async function main() {
136
156
  }
137
157
 
138
158
  // Reverse-verify: fetch and loudly print the target account identity
139
- // before granting, so a wrong userId is caught by eye (gateway#923).
159
+ // before any mutation, so a wrong userId is caught by eye (gateway#923).
140
160
  const acct = await getUserById(env, userId);
141
161
  console.log(
142
162
  `🎯 目标账号: userId=${userId} 手机=${acct.phone || '(无)'} email=${acct.email || '(无)'} 当前plan=${acct.current_plan || '?'}`,
143
163
  );
144
164
 
145
165
  // Hard assertion: a phone-input grant must land on an account whose phone
146
- // matches. Runs BEFORE callBilling — a mismatch aborts without granting.
166
+ // matches. Runs BEFORE the caller's mutation — a mismatch aborts.
147
167
  if (kind === 'phone') {
148
168
  assertPhoneMatch(identifier, acct.phone);
149
169
  }
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 };
170
+ return { userId, kind, identity: { phone: acct.phone, email: acct.email } };
159
171
  }
160
172
 
173
+ // AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No internal
174
+ // HTTP reverse-verify (token lacks internal:users:write → 403), but still
175
+ // echo the resolved account so destructive ops have a confirmation line (R1).
176
+ const infisicalConfig = getInfisicalConfig();
177
+ const token = getInfisicalToken(infisicalConfig);
178
+ const userId = await resolveUserId(identifier, env, infisicalConfig, token);
179
+ console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
180
+ return { userId, kind, identity: { phone: null, email: identifier } };
181
+ }
182
+
183
+ async function main() {
184
+ const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
185
+
186
+ console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${classifyIdentifier(identifier)}) for ${months} month(s) [${env.toUpperCase()}]\n`);
187
+
188
+ // Shared resolver: classify → resolve → reverse-verify echo → phone-assert
189
+ // (cn-prod/cn-stage), or email-only via SSH tunnel (AWS). See resolveTargetUser.
190
+ const { userId, identity } = await resolveTargetUser(env, identifier);
191
+
161
192
  const { body } = await callBilling<{
162
193
  success: boolean;
163
194
  subscriptionId: string;
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ // optima-account:以用户为中心的运营 admin 操作。
5
+ // · status — 只读聚合(订阅 + 权益)。M2M。
6
+ // · ban / unban — user-auth 账号禁用/恢复。需 admin-用户 token(非 M2M)。
7
+ //
8
+ // 标识符 <email|phone|userId>:cn-prod/cn-stage 支持三种,AWS stage/prod 仅 email
9
+ // (见 resolveTargetUser)。两个生产环境(prod/cn-prod)ban/unban 前会要求确认。
10
+ const grant_subscription_1 = require("./grant-subscription");
11
+ const billing_http_1 = require("./billing-http");
12
+ const confirm_prompt_1 = require("./confirm-prompt");
13
+ function parseArgs(argv, opts = {}) {
14
+ const out = { env: 'stage', reason: null, yes: false };
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const a = argv[i];
17
+ const next = argv[i + 1];
18
+ if (a === '--env') {
19
+ out.env = next;
20
+ i++;
21
+ }
22
+ else if (a === '--reason') {
23
+ out.reason = next;
24
+ i++;
25
+ }
26
+ else if (a === '--yes') {
27
+ out.yes = true;
28
+ }
29
+ else if (a.startsWith('--')) {
30
+ throw new Error(`Unknown arg: ${a}`);
31
+ }
32
+ else if (out.identifier) {
33
+ throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
34
+ }
35
+ else {
36
+ out.identifier = a;
37
+ }
38
+ }
39
+ if (!out.identifier)
40
+ throw new Error('target user required: <email|phone|userId>');
41
+ (0, billing_http_1.validateEnvCnProd)(out.env);
42
+ if (opts.reason && !out.reason)
43
+ throw new Error('--reason required for ban');
44
+ return out;
45
+ }
46
+ async function runStatus(argv) {
47
+ const { identifier, env } = parseArgs(argv);
48
+ const { userId, identity } = await (0, grant_subscription_1.resolveTargetUser)(env, identifier);
49
+ // 订阅(membership-status,M2M;路径前缀 /api/internal 经 billing base URL)
50
+ let sub = '(读取失败)';
51
+ try {
52
+ const { body } = await (0, billing_http_1.callBilling)(env, 'GET', `/api/internal/users/${encodeURIComponent(userId)}/membership-status`);
53
+ sub = `active=${body.active} plan=${body.planId} status=${body.status}`;
54
+ }
55
+ catch (e) {
56
+ sub = `(读取失败: ${e.message})`;
57
+ }
58
+ // 权益(admin/entitlements,M2M)
59
+ let ents = '(读取失败)';
60
+ try {
61
+ const { body } = await (0, billing_http_1.callBilling)(env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
62
+ const rows = body.entitlements ?? [];
63
+ ents = rows.length ? rows.map((r) => `${r.productKey}(${r.status})`).join(', ') : '(无)';
64
+ }
65
+ catch (e) {
66
+ ents = `(读取失败: ${e.message})`;
67
+ }
68
+ console.log(`\n=== 账号状态 [${env.toUpperCase()}] ===`);
69
+ console.log(`userId : ${userId}`);
70
+ console.log(`手机 : ${identity.phone || '(无)'}`);
71
+ console.log(`email : ${identity.email || '(无)'}`);
72
+ console.log(`订阅 : ${sub}`);
73
+ console.log(`权益 : ${ents}`);
74
+ console.log(`禁用态/credits 余额: 暂未纳入(banned_at 与 credits 无 M2M 读取端点,见 optima-dev-skills#36)\n`);
75
+ }
76
+ async function runBan(argv) {
77
+ const { identifier, env, reason, yes } = parseArgs(argv, { reason: true });
78
+ const { userId, identity } = await (0, grant_subscription_1.resolveTargetUser)(env, identifier);
79
+ await (0, confirm_prompt_1.confirmIfProd)(env, `Action: BAN userId=${userId} (手机=${identity.phone || '(无)'} email=${identity.email || '(无)'}) on ${env.toUpperCase()}\nReason: ${reason}`, yes);
80
+ console.log(`\n🚫 Banning ${identifier} (userId=${userId})...`);
81
+ const res = await (0, billing_http_1.callUserAuthAsAdmin)(env, 'POST', `/api/v1/admin/users/${encodeURIComponent(userId)}/ban`, { reason });
82
+ console.log(`✓ Banned (HTTP ${res.status})`);
83
+ console.log('⚠️ 注意:ban 仅置 is_active=false——挡新登录/刷新,但**不会立即失效已签发的 access token**(活跃会话到 token 过期才失效)。即时踢会话需 user-auth 后续支持。\n');
84
+ }
85
+ async function runUnban(argv) {
86
+ const { identifier, env, yes } = parseArgs(argv);
87
+ const { userId, identity } = await (0, grant_subscription_1.resolveTargetUser)(env, identifier);
88
+ await (0, confirm_prompt_1.confirmIfProd)(env, `Action: UNBAN userId=${userId} (手机=${identity.phone || '(无)'} email=${identity.email || '(无)'}) on ${env.toUpperCase()}`, yes);
89
+ console.log(`\n♻️ Unbanning ${identifier} (userId=${userId})...`);
90
+ const res = await (0, billing_http_1.callUserAuthAsAdmin)(env, 'POST', `/api/v1/admin/users/${encodeURIComponent(userId)}/unban`);
91
+ console.log(`✓ Unbanned (HTTP ${res.status})\n`);
92
+ }
93
+ function printHelp() {
94
+ console.log(`Usage: optima-account <subcommand> <email|phone|userId> [options]
95
+
96
+ Subcommands:
97
+ status 只读聚合:订阅(membership) + 权益(entitlements)
98
+ ban 禁用账号(user-auth is_active=false)。需 --reason
99
+ unban 恢复账号
100
+
101
+ Target user: <email|phone|userId> (positional). phone/userId only on
102
+ cn-prod / cn-stage; AWS stage/prod resolve email only.
103
+
104
+ Options:
105
+ --reason "..." Ban reason (required for ban; stored on the user)
106
+ --yes Skip prod/cn-prod confirmation prompt
107
+ --env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
108
+
109
+ Notes:
110
+ · ban/unban 用 admin-用户 token(Infisical /shared-secrets/credentials)。
111
+ · ban 非即时踢会话:仅挡新登录/刷新,活跃 token 过期后失效。
112
+ · 禁用原因/banned_at 与 credits 余额暂不在 status 显示(见 #36)。
113
+
114
+ Examples:
115
+ optima-account status 18898654855 --env cn-prod
116
+ optima-account ban user@example.com --reason "abuse" --env prod
117
+ optima-account unban 18898654855 --env cn-prod`);
118
+ }
119
+ async function main() {
120
+ const [, , subcommand, ...rest] = process.argv;
121
+ if (!subcommand || subcommand === '-h' || subcommand === '--help') {
122
+ printHelp();
123
+ process.exit(0);
124
+ }
125
+ switch (subcommand) {
126
+ case 'status':
127
+ await runStatus(rest);
128
+ break;
129
+ case 'ban':
130
+ await runBan(rest);
131
+ break;
132
+ case 'unban':
133
+ await runUnban(rest);
134
+ break;
135
+ default:
136
+ console.error(`Unknown subcommand: ${subcommand}`);
137
+ printHelp();
138
+ process.exit(1);
139
+ }
140
+ }
141
+ main().catch((err) => { console.error('\n❌ Error:', err.message); process.exit(1); });