@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.
- package/.claude/skills/account/SKILL.md +61 -0
- package/.claude/skills/entitlement/SKILL.md +55 -0
- package/.claude/skills/grant-balance/SKILL.md +3 -3
- package/.claude/skills/grant-subscription/SKILL.md +4 -4
- package/.codex/skills/account/SKILL.md +61 -0
- package/.codex/skills/entitlement/SKILL.md +55 -0
- package/.codex/skills/grant-balance/SKILL.md +3 -3
- package/.codex/skills/grant-subscription/SKILL.md +1 -1
- package/README.md +6 -0
- package/bin/helpers/account.ts +146 -0
- package/bin/helpers/billing-http.ts +105 -19
- package/bin/helpers/entitlement/grant.ts +18 -12
- package/bin/helpers/entitlement/list.ts +15 -11
- package/bin/helpers/entitlement/revoke.ts +18 -14
- package/bin/helpers/grant-balance.ts +17 -21
- package/bin/helpers/grant-subscription.ts +47 -21
- package/dist/bin/helpers/account.js +141 -0
- package/dist/bin/helpers/billing-http.js +99 -19
- package/dist/bin/helpers/entitlement/grant.js +20 -12
- package/dist/bin/helpers/entitlement/list.js +17 -11
- package/dist/bin/helpers/entitlement/revoke.js +20 -14
- package/dist/bin/helpers/grant-balance.js +15 -20
- package/dist/bin/helpers/grant-subscription.js +37 -20
- package/docs/.admin-cli-4env/spec.md +109 -0
- package/package.json +2 -1
|
@@ -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
|
|
9
|
+
console.log(`Usage: optima-entitlement grant <email|phone|userId> --product-key <slug> --justification "..." [options]
|
|
9
10
|
|
|
10
11
|
Required:
|
|
11
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
50
|
-
throw new Error('--email
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
8
|
+
console.log(`Usage: optima-entitlement list <email|phone|userId> [options]
|
|
8
9
|
|
|
9
10
|
Required:
|
|
10
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
33
|
-
throw new Error('--email
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
|
9
|
+
console.log(`Usage: optima-entitlement revoke <email|phone|userId> --product-key <slug> --reason "..." [options]
|
|
9
10
|
|
|
10
11
|
Required:
|
|
11
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
51
|
-
throw new Error('--email
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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 {
|
|
54
|
+
return { identifier, amountUsd, description, env };
|
|
53
55
|
}
|
|
54
56
|
async function main() {
|
|
55
|
-
const {
|
|
56
|
-
console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${
|
|
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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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! ${
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
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
|
|
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})`);
|