@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.
- package/.claude/commands/logs.md +27 -47
- 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/cli.js +1 -1
- 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/bin/helpers/logs.ts +200 -0
- 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/dist/bin/helpers/logs.js +207 -0
- package/docs/.admin-cli-4env/spec.md +109 -0
- package/package.json +3 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
15
|
+
console.log(`Usage: optima-entitlement grant <email|phone|userId> --product-key <slug> --justification "..." [options]
|
|
15
16
|
|
|
16
17
|
Required:
|
|
17
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
1
|
+
import { callBilling, validateEnvCnProd } from '../billing-http';
|
|
2
|
+
import { resolveTargetUser } from '../grant-subscription';
|
|
2
3
|
|
|
3
4
|
interface ListArgs {
|
|
4
|
-
|
|
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
|
|
11
|
+
console.log(`Usage: optima-entitlement list <email|phone|userId> [options]
|
|
11
12
|
|
|
12
13
|
Required:
|
|
13
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
22
|
+
console.log(`Usage: optima-entitlement revoke <email|phone|userId> --product-key <slug> --reason "..." [options]
|
|
22
23
|
|
|
23
24
|
Required:
|
|
24
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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 {
|
|
9
|
-
import {
|
|
8
|
+
import { callBilling, validateEnvCnProd } from './billing-http';
|
|
9
|
+
import { resolveTargetUser } from './grant-subscription';
|
|
10
10
|
|
|
11
|
-
function parseArgs(args: 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
|
|
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
|
|
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 {
|
|
52
|
+
return { identifier, amountUsd, description, env };
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
async function main() {
|
|
54
|
-
const {
|
|
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 ${
|
|
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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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! ${
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|