@optima-chat/dev-skills 0.7.38 → 0.7.40
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/grant-balance/SKILL.md +10 -8
- package/.claude/skills/grant-subscription/SKILL.md +12 -6
- package/.codex/skills/grant-balance/SKILL.md +10 -8
- package/.codex/skills/grant-subscription/SKILL.md +8 -1
- package/bin/helpers/billing-http.ts +83 -3
- package/bin/helpers/db-utils.ts +274 -37
- package/bin/helpers/grant-balance.ts +39 -46
- package/bin/helpers/grant-subscription.ts +47 -89
- package/bin/helpers/query-db.ts +25 -60
- package/bin/helpers/show-env.ts +3 -28
- package/dist/bin/helpers/billing-http.js +82 -3
- package/dist/bin/helpers/db-utils.js +292 -45
- package/dist/bin/helpers/discount.js +0 -0
- package/dist/bin/helpers/entitlement.js +0 -0
- package/dist/bin/helpers/generate-test-token.js +0 -0
- package/dist/bin/helpers/grant-balance.js +35 -43
- package/dist/bin/helpers/grant-subscription.js +36 -86
- package/dist/bin/helpers/plugin.js +0 -0
- package/dist/bin/helpers/product.js +0 -0
- package/dist/bin/helpers/query-db.js +33 -49
- package/dist/bin/helpers/show-env.js +5 -17
- package/dist/bin/helpers/verify-health.js +0 -0
- package/package.json +1 -1
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// P15 D8b:USD 钱包退役——原「SSH 直写 subscriptions/usd_wallets/token_quotas」
|
|
4
|
+
// 作废,改调 billing 服务态端点(与用户态 /api/admin/grant-subscription 同
|
|
5
|
+
// 业务体:supersede 旧授予 + 实得 credits + token quota,重试双发不双倍)。
|
|
6
|
+
import { getInfisicalConfig, getInfisicalToken, resolveUserId } from './db-utils';
|
|
7
|
+
import { callBilling, resolveUserIdByEmail, validateEnvCnProd } from './billing-http';
|
|
8
|
+
|
|
9
|
+
// cn-prod sells the CNY-priced -cn plans (P12); the bare USD plan ids also
|
|
10
|
+
// exist in the cn DB, so a per-env whitelist (not billing-side validation)
|
|
11
|
+
// is what prevents accidentally granting a USD-priced plan to a CN user.
|
|
12
|
+
const PLANS_BY_ENV: Record<string, string[]> = {
|
|
13
|
+
stage: ['trial', 'starter', 'pro', 'enterprise'],
|
|
14
|
+
prod: ['trial', 'starter', 'pro', 'enterprise'],
|
|
15
|
+
'cn-prod': ['trial', 'starter-cn', 'pro-cn', 'enterprise-cn'],
|
|
16
|
+
};
|
|
4
17
|
|
|
5
18
|
function parseArgs(args: string[]): { email: string; plan: string; months: number; env: string } {
|
|
6
19
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
@@ -8,14 +21,15 @@ function parseArgs(args: string[]): { email: string; plan: string; months: numbe
|
|
|
8
21
|
|
|
9
22
|
Options:
|
|
10
23
|
--plan <id> Plan: trial, starter, pro, enterprise (default: pro)
|
|
24
|
+
cn-prod plans: trial, starter-cn, pro-cn, enterprise-cn (default: pro-cn)
|
|
11
25
|
--months <n> Duration in months (default: 1)
|
|
12
|
-
--env <env> Environment: stage, prod (default: stage)
|
|
26
|
+
--env <env> Environment: stage, prod, cn-prod (default: stage)
|
|
13
27
|
-h, --help Show this help`);
|
|
14
28
|
process.exit(0);
|
|
15
29
|
}
|
|
16
30
|
|
|
17
31
|
const email = args[0];
|
|
18
|
-
let plan =
|
|
32
|
+
let plan: string | null = null;
|
|
19
33
|
let months = 1;
|
|
20
34
|
let env = 'stage';
|
|
21
35
|
|
|
@@ -25,106 +39,50 @@ Options:
|
|
|
25
39
|
else if (args[i] === '--env' && args[i + 1]) { env = args[++i]; }
|
|
26
40
|
}
|
|
27
41
|
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
validateEnvCnProd(env);
|
|
43
|
+
plan = plan ?? (env === 'cn-prod' ? 'pro-cn' : 'pro');
|
|
44
|
+
const allowed = PLANS_BY_ENV[env];
|
|
45
|
+
if (!allowed.includes(plan)) {
|
|
46
|
+
console.error(`Unknown plan for ${env}: ${plan}. Available: ${allowed.join(', ')}`);
|
|
30
47
|
process.exit(1);
|
|
31
48
|
}
|
|
32
49
|
if (months < 1) { console.error('Months must be >= 1'); process.exit(1); }
|
|
33
|
-
if (!['stage', 'prod'].includes(env)) { console.error('Env must be stage or prod (billing DB not available in CI)'); process.exit(1); }
|
|
34
50
|
|
|
35
51
|
return { email, plan, months, env };
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
async function main() {
|
|
39
55
|
const { email, plan, months, env } = parseArgs(process.argv.slice(2));
|
|
40
|
-
const infisicalConfig = getInfisicalConfig();
|
|
41
|
-
const token = getInfisicalToken(infisicalConfig);
|
|
42
56
|
|
|
43
57
|
console.log(`\n🎁 Granting ${plan} subscription to ${email} for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
44
58
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const [planName, monthlyCreditsStr, sessionTokenLimitStr, weeklyTokenLimitStr] = planRow.split('|');
|
|
55
|
-
const monthlyCredits = parseInt(monthlyCreditsStr, 10);
|
|
56
|
-
const grantMicros = monthlyCredits * 10000; // 1 credit = $0.01 = 10,000 micros
|
|
57
|
-
const sessionTokenLimit = parseInt(sessionTokenLimitStr, 10);
|
|
58
|
-
const weeklyTokenLimit = parseInt(weeklyTokenLimitStr, 10);
|
|
59
|
-
console.log(`✓ Plan: ${planName} (grant: $${(grantMicros / 1000000).toFixed(2)}, session: ${sessionTokenLimit.toLocaleString()}, weekly: ${weeklyTokenLimit.toLocaleString()})`);
|
|
60
|
-
|
|
61
|
-
// Execute all mutations in a single transaction
|
|
62
|
-
const now = new Date().toISOString();
|
|
63
|
-
const periodEnd = new Date();
|
|
64
|
-
periodEnd.setMonth(periodEnd.getMonth() + months);
|
|
65
|
-
const periodEndISO = periodEnd.toISOString();
|
|
66
|
-
const sessionEnd = new Date(new Date().getTime() + 5 * 60 * 60 * 1000).toISOString();
|
|
67
|
-
const weekEnd = new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
68
|
-
|
|
69
|
-
const safeUserId = escapeSQL(userId);
|
|
70
|
-
const safePlan = escapeSQL(plan);
|
|
71
|
-
const safePlanName = escapeSQL(planName);
|
|
72
|
-
|
|
73
|
-
console.log('Executing transaction...');
|
|
74
|
-
const txSQL = `
|
|
75
|
-
BEGIN;
|
|
76
|
-
|
|
77
|
-
-- Cancel active subscriptions
|
|
78
|
-
UPDATE subscriptions SET status='canceled', canceled_at='${now}'
|
|
79
|
-
WHERE user_id='${safeUserId}' AND status IN ('active','trialing');
|
|
80
|
-
|
|
81
|
-
-- Create new subscription
|
|
82
|
-
INSERT INTO subscriptions (id, user_id, plan_id, status, billing_interval, current_period_start, current_period_end, created_at, updated_at)
|
|
83
|
-
VALUES (concat('sub_gift_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'active', 'monthly', '${now}', '${periodEndISO}', '${now}', '${now}');
|
|
84
|
-
|
|
85
|
-
-- Ensure wallet exists (upsert)
|
|
86
|
-
INSERT INTO usd_wallets (id, user_id, balance_micros, reserved_micros, granted_balance_micros, created_at, updated_at)
|
|
87
|
-
VALUES (gen_random_uuid(), '${safeUserId}', 0, 0, 0, '${now}', '${now}')
|
|
88
|
-
ON CONFLICT (user_id) DO NOTHING;
|
|
89
|
-
|
|
90
|
-
-- Reset granted balance and set new grant
|
|
91
|
-
UPDATE usd_wallets SET granted_balance_micros = ${grantMicros}, updated_at = '${now}'
|
|
92
|
-
WHERE user_id = '${safeUserId}';
|
|
93
|
-
|
|
94
|
-
-- Record topup for audit trail
|
|
95
|
-
INSERT INTO usd_wallet_topups (id, wallet_id, amount_micros, service_fee_micros, net_credit_micros, status, source, service_namespace, created_at, completed_at)
|
|
96
|
-
SELECT gen_random_uuid(), w.id, ${grantMicros}, 0, ${grantMicros}, 'completed', 'subscription_grant', 'platform', '${now}', '${now}'
|
|
97
|
-
FROM usd_wallets w WHERE w.user_id = '${safeUserId}';
|
|
98
|
-
|
|
99
|
-
-- Update existing active session quota, or insert new one if none exists
|
|
100
|
-
UPDATE token_quotas SET plan_id='${safePlan}', monthly_limit=${sessionTokenLimit}, updated_at='${now}'
|
|
101
|
-
WHERE user_id='${safeUserId}' AND period_type='session' AND period_end > '${now}';
|
|
102
|
-
|
|
103
|
-
INSERT INTO token_quotas (id, user_id, plan_id, period_type, monthly_limit, monthly_used, period_start, period_end, created_at, updated_at)
|
|
104
|
-
SELECT concat('tq_sess_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'session', ${sessionTokenLimit}, 0, '${now}', '${sessionEnd}', '${now}', '${now}'
|
|
105
|
-
WHERE NOT EXISTS (SELECT 1 FROM token_quotas WHERE user_id='${safeUserId}' AND period_type='session' AND period_end > '${now}');
|
|
106
|
-
|
|
107
|
-
-- Update existing active weekly quota, or insert new one if none exists
|
|
108
|
-
UPDATE token_quotas SET plan_id='${safePlan}', monthly_limit=${weeklyTokenLimit}, updated_at='${now}'
|
|
109
|
-
WHERE user_id='${safeUserId}' AND period_type='weekly' AND period_end > '${now}';
|
|
110
|
-
|
|
111
|
-
INSERT INTO token_quotas (id, user_id, plan_id, period_type, monthly_limit, monthly_used, period_start, period_end, created_at, updated_at)
|
|
112
|
-
SELECT concat('tq_week_', substr(md5(random()::text), 1, 16)), '${safeUserId}', '${safePlan}', 'weekly', ${weeklyTokenLimit}, 0, '${now}', '${weekEnd}', '${now}', '${now}'
|
|
113
|
-
WHERE NOT EXISTS (SELECT 1 FROM token_quotas WHERE user_id='${safeUserId}' AND period_type='weekly' AND period_end > '${now}');
|
|
114
|
-
|
|
115
|
-
COMMIT;
|
|
116
|
-
`.trim();
|
|
117
|
-
|
|
118
|
-
bq(txSQL);
|
|
119
|
-
|
|
120
|
-
console.log('✓ Old subscriptions canceled');
|
|
121
|
-
console.log(`✓ ${planName} subscription created (expires: ${periodEnd.toLocaleDateString()})`);
|
|
122
|
-
if (grantMicros > 0) {
|
|
123
|
-
console.log(`✓ Wallet granted $${(grantMicros / 1000000).toFixed(2)} (${monthlyCredits} credits equivalent)`);
|
|
59
|
+
// cn-prod has no SSH tunnel into the Aliyun RDS — resolve via user-auth's
|
|
60
|
+
// internal lookup API instead of the direct SQL path.
|
|
61
|
+
let userId: string;
|
|
62
|
+
if (env === 'cn-prod') {
|
|
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);
|
|
124
68
|
}
|
|
125
|
-
console.log(`✓ Token quotas updated (session: ${sessionTokenLimit.toLocaleString()}, weekly: ${weeklyTokenLimit.toLocaleString()})`);
|
|
126
69
|
|
|
127
|
-
|
|
70
|
+
const { body } = await callBilling<{
|
|
71
|
+
success: boolean;
|
|
72
|
+
subscriptionId: string;
|
|
73
|
+
planId: string;
|
|
74
|
+
credits: number;
|
|
75
|
+
sessionTokenLimit: number;
|
|
76
|
+
weeklyTokenLimit: number;
|
|
77
|
+
expiresAt: string;
|
|
78
|
+
warning?: string;
|
|
79
|
+
}>(env, 'POST', '/api/billing/admin/grant-subscription', { userId, planId: plan, months });
|
|
80
|
+
|
|
81
|
+
console.log(`✓ Subscription ${body.subscriptionId} (${body.planId})`);
|
|
82
|
+
console.log(`✓ Credits: ${body.credits.toLocaleString()} (expires ${body.expiresAt})`);
|
|
83
|
+
console.log(`✓ Token limits: session ${body.sessionTokenLimit.toLocaleString()} / weekly ${body.weeklyTokenLimit.toLocaleString()}`);
|
|
84
|
+
if (body.warning) console.log(`⚠️ ${body.warning}`);
|
|
85
|
+
console.log(`\n✅ Done! ${email} now has ${body.planId} until ${body.expiresAt}\n`);
|
|
128
86
|
}
|
|
129
87
|
|
|
130
88
|
main().catch(error => {
|
package/bin/helpers/query-db.ts
CHANGED
|
@@ -2,14 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
4
|
import * as fs from 'fs';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
interface InfisicalConfig {
|
|
8
|
-
url: string;
|
|
9
|
-
clientId: string;
|
|
10
|
-
clientSecret: string;
|
|
11
|
-
projectId: string;
|
|
12
|
-
}
|
|
5
|
+
import { ensureTunnel, getGitHubVariable, getInfisicalConfig, getInfisicalToken, getInfisicalSecrets, parseDatabaseUrl, isCnEnv, connectCnDB, connectCnDBFromUrl } from './db-utils';
|
|
13
6
|
|
|
14
7
|
interface DatabaseConfig {
|
|
15
8
|
host: string;
|
|
@@ -97,54 +90,8 @@ const RDS_HOSTS = {
|
|
|
97
90
|
prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com'
|
|
98
91
|
};
|
|
99
92
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
|
|
103
|
-
if (!match) {
|
|
104
|
-
throw new Error(`Failed to parse DATABASE_URL: ${url}`);
|
|
105
|
-
}
|
|
106
|
-
return {
|
|
107
|
-
user: decodeURIComponent(match[1]),
|
|
108
|
-
password: decodeURIComponent(match[2]),
|
|
109
|
-
host: match[3],
|
|
110
|
-
port: parseInt(match[4]),
|
|
111
|
-
database: match[5]
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function getGitHubVariable(name: string): string {
|
|
116
|
-
return execSync(`gh variable get ${name} -R Optima-Chat/optima-dev-skills`, { encoding: 'utf-8' }).trim();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function getInfisicalConfig(): InfisicalConfig {
|
|
120
|
-
return {
|
|
121
|
-
url: getGitHubVariable('INFISICAL_URL'),
|
|
122
|
-
clientId: getGitHubVariable('INFISICAL_CLIENT_ID'),
|
|
123
|
-
clientSecret: getGitHubVariable('INFISICAL_CLIENT_SECRET'),
|
|
124
|
-
projectId: getGitHubVariable('INFISICAL_PROJECT_ID')
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function getInfisicalToken(config: InfisicalConfig): string {
|
|
129
|
-
const response = execSync(
|
|
130
|
-
`curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`,
|
|
131
|
-
{ encoding: 'utf-8' }
|
|
132
|
-
);
|
|
133
|
-
return JSON.parse(response).accessToken;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record<string, string> {
|
|
137
|
-
const response = execSync(
|
|
138
|
-
`curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${secretPath}" -H "Authorization: Bearer ${token}"`,
|
|
139
|
-
{ encoding: 'utf-8' }
|
|
140
|
-
);
|
|
141
|
-
const data = JSON.parse(response);
|
|
142
|
-
const secrets: Record<string, string> = {};
|
|
143
|
-
for (const secret of data.secrets || []) {
|
|
144
|
-
secrets[secret.secretKey] = secret.secretValue;
|
|
145
|
-
}
|
|
146
|
-
return secrets;
|
|
147
|
-
}
|
|
93
|
+
// parseDatabaseUrl 统一用 db-utils 的实现:右切 userinfo 容忍密码特殊字符,
|
|
94
|
+
// 且报错不回显 URL(这里曾把含密码的完整 URL 打进错误信息)。
|
|
148
95
|
|
|
149
96
|
function findPsqlPath(): string {
|
|
150
97
|
// 1. 优先从 PATH 中查找
|
|
@@ -210,7 +157,7 @@ async function main() {
|
|
|
210
157
|
console.error('Usage: query-db.ts <service> <sql> [environment]');
|
|
211
158
|
console.error('');
|
|
212
159
|
console.error('Services: commerce-backend, user-auth, agentic-chat, bi-backend, session-gateway, gateway-core, optima-logistics, billing, ads-backend, amazon-backend, browser-backend, shopify-backend, optima-generation, optima-sentinel');
|
|
213
|
-
console.error('Environments: ci (default), stage, prod');
|
|
160
|
+
console.error('Environments: ci (default), stage, prod, cn (阿里云 cn-prod)');
|
|
214
161
|
console.error('');
|
|
215
162
|
console.error('Example: query-db.ts user-auth "SELECT COUNT(*) FROM users" prod');
|
|
216
163
|
process.exit(1);
|
|
@@ -224,6 +171,26 @@ async function main() {
|
|
|
224
171
|
process.exit(1);
|
|
225
172
|
}
|
|
226
173
|
|
|
174
|
+
// cn-prod(阿里云):独立 Infisical + 经 buildbox 跳板连内网 RDS(动态端口隧道)。
|
|
175
|
+
// 两类 cred:① shared-secrets/database-users(按 prefix)② 服务自己的 DATABASE_URL(展开引用)。
|
|
176
|
+
if (isCnEnv(environment)) {
|
|
177
|
+
const prodCfg = SERVICE_DB_MAP[service as keyof typeof SERVICE_DB_MAP].prod as any;
|
|
178
|
+
let db: { query: (sql: string) => string };
|
|
179
|
+
if (prodCfg?.userKey) {
|
|
180
|
+
const prefix = prodCfg.userKey.replace(/_DB_USER$/, '');
|
|
181
|
+
console.log(`\n🔍 Querying ${service} (CN-PROD, prefix ${prefix})...`);
|
|
182
|
+
db = connectCnDB(prefix);
|
|
183
|
+
} else if (prodCfg?.databaseUrlPath) {
|
|
184
|
+
console.log(`\n🔍 Querying ${service} (CN-PROD, DATABASE_URL @ ${prodCfg.databaseUrlPath})...`);
|
|
185
|
+
db = connectCnDBFromUrl(prodCfg.databaseUrlPath);
|
|
186
|
+
} else {
|
|
187
|
+
console.error(`cn-prod query 暂不支持 ${service}(既无 userKey 也无 databaseUrlPath)。见 optima-dev-skills#21。`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
console.log('\n' + db.query(sql));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
227
194
|
const serviceConfig = SERVICE_DB_MAP[service as keyof typeof SERVICE_DB_MAP][environment as 'ci' | 'stage' | 'prod'];
|
|
228
195
|
|
|
229
196
|
if (!serviceConfig) {
|
|
@@ -296,9 +263,7 @@ async function main() {
|
|
|
296
263
|
}
|
|
297
264
|
}
|
|
298
265
|
|
|
299
|
-
const localPort =
|
|
300
|
-
|
|
301
|
-
setupTunnel(dbHost, localPort);
|
|
266
|
+
const localPort = ensureTunnel(dbHost);
|
|
302
267
|
|
|
303
268
|
const result = queryDatabase('localhost', localPort, dbUser, dbPassword, database, sql);
|
|
304
269
|
console.log('\n' + result);
|
package/bin/helpers/show-env.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
|
-
|
|
5
|
-
interface InfisicalConfig {
|
|
6
|
-
url: string;
|
|
7
|
-
clientId: string;
|
|
8
|
-
clientSecret: string;
|
|
9
|
-
projectId: string;
|
|
10
|
-
}
|
|
4
|
+
import { getInfisicalConfig, getInfisicalToken, InfisicalConfig } from './db-utils';
|
|
11
5
|
|
|
12
6
|
// 支持的服务列表(Infisical 路径为 /services/<service-name>)
|
|
13
7
|
const SUPPORTED_SERVICES = [
|
|
@@ -44,27 +38,8 @@ const ENV_MAP: Record<string, string> = {
|
|
|
44
38
|
prod: 'prod'
|
|
45
39
|
};
|
|
46
40
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function getInfisicalConfig(): InfisicalConfig {
|
|
52
|
-
return {
|
|
53
|
-
url: getGitHubVariable('INFISICAL_URL'),
|
|
54
|
-
clientId: getGitHubVariable('INFISICAL_CLIENT_ID'),
|
|
55
|
-
clientSecret: getGitHubVariable('INFISICAL_CLIENT_SECRET'),
|
|
56
|
-
projectId: getGitHubVariable('INFISICAL_PROJECT_ID')
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function getInfisicalToken(config: InfisicalConfig): string {
|
|
61
|
-
const response = execSync(
|
|
62
|
-
`curl -s -X POST "${config.url}/api/v1/auth/universal-auth/login" -H "Content-Type: application/json" -d '{"clientId": "${config.clientId}", "clientSecret": "${config.clientSecret}"}'`,
|
|
63
|
-
{ encoding: 'utf-8' }
|
|
64
|
-
);
|
|
65
|
-
return JSON.parse(response).accessToken;
|
|
66
|
-
}
|
|
67
|
-
|
|
41
|
+
// NOTE: getInfisicalSecrets is kept local because it encodes secretPath (encodeURIComponent),
|
|
42
|
+
// unlike db-utils' raw-path variant; getGitHubVariable/getInfisicalConfig/getInfisicalToken are shared from db-utils.
|
|
68
43
|
function getInfisicalSecrets(config: InfisicalConfig, token: string, environment: string, secretPath: string): Record<string, string> {
|
|
69
44
|
const response = execSync(
|
|
70
45
|
`curl -s "${config.url}/api/v3/secrets/raw?workspaceId=${config.projectId}&environment=${environment}&secretPath=${encodeURIComponent(secretPath)}" -H "Authorization: Bearer ${token}"`,
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.validateEnv = validateEnv;
|
|
4
|
+
exports.validateEnvCnProd = validateEnvCnProd;
|
|
4
5
|
exports.getServiceToken = getServiceToken;
|
|
5
6
|
exports.callBilling = callBilling;
|
|
6
7
|
exports.callSkills = callSkills;
|
|
8
|
+
exports.resolveUserIdByEmail = resolveUserIdByEmail;
|
|
7
9
|
const child_process_1 = require("child_process");
|
|
8
10
|
const infisical_secrets_1 = require("./infisical-secrets");
|
|
9
11
|
const db_utils_1 = require("./db-utils");
|
|
10
12
|
const USER_AUTH_URLS = {
|
|
11
13
|
stage: 'https://auth.stage.optima.onl',
|
|
12
14
|
prod: 'https://auth.optima.onl',
|
|
15
|
+
'cn-prod': 'https://auth-cn.optima.chat',
|
|
13
16
|
};
|
|
17
|
+
// cn-prod URLs are hardcoded: cn Infisical (secrets-cn.optima.chat) is a
|
|
18
|
+
// separate instance dev-skills has no machine identity for, and these domains
|
|
19
|
+
// are stable. AWS envs keep reading /shared-secrets/domain-urls.
|
|
20
|
+
const CN_PROD_BILLING_URL = 'https://billing-cn.optima.chat';
|
|
14
21
|
/**
|
|
15
22
|
* Validate the --env flag value at command entry, before any I/O.
|
|
16
23
|
*
|
|
@@ -27,12 +34,38 @@ function validateEnv(env) {
|
|
|
27
34
|
}
|
|
28
35
|
return env;
|
|
29
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Variant for commands that also support cn-prod — currently grant-balance /
|
|
39
|
+
* grant-subscription, which reach billing + user-auth over HTTPS only.
|
|
40
|
+
* Other commands resolve users via the AWS RDS SSH tunnel, which does not
|
|
41
|
+
* exist for cn-prod (Aliyun VPC-internal RDS) — keep them on validateEnv so a
|
|
42
|
+
* cn-prod typo fails fast instead of dying inside the tunnel setup.
|
|
43
|
+
*/
|
|
44
|
+
function validateEnvCnProd(env) {
|
|
45
|
+
if (env !== 'stage' && env !== 'prod' && env !== 'cn-prod') {
|
|
46
|
+
throw new Error(`--env must be "stage", "prod" or "cn-prod" (got: ${env})`);
|
|
47
|
+
}
|
|
48
|
+
return env;
|
|
49
|
+
}
|
|
30
50
|
// T1 discovered: client_id differs per env (stage=dev-skills-ubd3qz6n,
|
|
31
51
|
// prod=dev-skills-hinxa0rs). Both stored in Infisical alongside the
|
|
32
52
|
// secret at /shared-secrets/oauth-clients/.
|
|
33
53
|
const DEV_SKILLS_OAUTH_PATH = '/shared-secrets/oauth-clients';
|
|
34
54
|
const DEV_SKILLS_CLIENT_ID_KEY = 'DEV_SKILLS_OAUTH_CLIENT_ID';
|
|
35
55
|
const DEV_SKILLS_CLIENT_SECRET_KEY = 'DEV_SKILLS_OAUTH_CLIENT_SECRET';
|
|
56
|
+
// cn-prod: the client (dev-skills-ecee51qo) lives in cn user-auth, but its
|
|
57
|
+
// credentials are mirrored into AWS Infisical (prod environment, same path)
|
|
58
|
+
// under CN_PROD-prefixed keys so we reuse the existing Infisical access —
|
|
59
|
+
// zero new credential chain. Canonical copy lives in cn Infisical
|
|
60
|
+
// /shared-secrets/oauth-clients (DEV_SKILLS_OAUTH_CLIENT_ID/SECRET).
|
|
61
|
+
const DEV_SKILLS_CN_CLIENT_ID_KEY = 'DEV_SKILLS_CN_PROD_OAUTH_CLIENT_ID';
|
|
62
|
+
const DEV_SKILLS_CN_CLIENT_SECRET_KEY = 'DEV_SKILLS_CN_PROD_OAUTH_CLIENT_SECRET';
|
|
63
|
+
// cn-prod tokens must carry this scope: resolveUserIdByEmail calls cn
|
|
64
|
+
// user-auth POST /api/v1/internal/users/lookup, whose guard
|
|
65
|
+
// (verify_internal_service_token) requires it. user-auth issues
|
|
66
|
+
// request∩allowed_scopes and an unscoped request yields scope="" (verified
|
|
67
|
+
// against cn-prod 2026-06-12), so the request must name it explicitly.
|
|
68
|
+
const CN_PROD_TOKEN_SCOPE = 'internal:users:write';
|
|
36
69
|
// ───── Cache (process-lifetime) ─────────────────────────────────────────────
|
|
37
70
|
// One CLI invocation does at most a handful of HTTP calls. We mint the M2M
|
|
38
71
|
// token once and reuse it. Cross-invocation re-mint is fine — JWT TTL is
|
|
@@ -47,6 +80,8 @@ const tokenCache = {};
|
|
|
47
80
|
const billingUrlCache = {};
|
|
48
81
|
const skillsUrlCache = {};
|
|
49
82
|
function getBillingUrl(env) {
|
|
83
|
+
if (env === 'cn-prod')
|
|
84
|
+
return CN_PROD_BILLING_URL;
|
|
50
85
|
if (billingUrlCache[env])
|
|
51
86
|
return billingUrlCache[env];
|
|
52
87
|
const url = (0, infisical_secrets_1.fetchInfisicalSecret)(env, '/shared-secrets/domain-urls', 'BILLING_URL');
|
|
@@ -66,12 +101,19 @@ function getServiceToken(env) {
|
|
|
66
101
|
const cfg = (0, db_utils_1.getInfisicalConfig)();
|
|
67
102
|
const tok = (0, db_utils_1.getInfisicalToken)(cfg);
|
|
68
103
|
// Fetch BOTH client_id and client_secret from Infisical — they differ per env.
|
|
69
|
-
|
|
70
|
-
|
|
104
|
+
// cn-prod credentials are mirrored in the AWS Infisical *prod* environment
|
|
105
|
+
// (fetchInfisicalSecret has no cn-prod env slug), under CN-specific keys.
|
|
106
|
+
const isCn = env === 'cn-prod';
|
|
107
|
+
const infisicalEnv = isCn ? 'prod' : env;
|
|
108
|
+
const idKey = isCn ? DEV_SKILLS_CN_CLIENT_ID_KEY : DEV_SKILLS_CLIENT_ID_KEY;
|
|
109
|
+
const secretKey = isCn ? DEV_SKILLS_CN_CLIENT_SECRET_KEY : DEV_SKILLS_CLIENT_SECRET_KEY;
|
|
110
|
+
const clientId = (0, infisical_secrets_1.fetchInfisicalSecret)(infisicalEnv, DEV_SKILLS_OAUTH_PATH, idKey, cfg, tok);
|
|
111
|
+
const clientSecret = (0, infisical_secrets_1.fetchInfisicalSecret)(infisicalEnv, DEV_SKILLS_OAUTH_PATH, secretKey, cfg, tok);
|
|
71
112
|
const authUrl = USER_AUTH_URLS[env];
|
|
72
113
|
if (!authUrl)
|
|
73
114
|
throw new Error(`Unknown env: ${env}`);
|
|
74
|
-
const
|
|
115
|
+
const scopeParam = isCn ? `&scope=${encodeURIComponent(CN_PROD_TOKEN_SCOPE)}` : '';
|
|
116
|
+
const body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}${scopeParam}`;
|
|
75
117
|
const response = (0, child_process_1.execSync)(`curl -s -X POST '${authUrl}/api/v1/oauth/token' -H 'Content-Type: application/x-www-form-urlencoded' -d '${body}'`, { encoding: 'utf-8' });
|
|
76
118
|
let parsed;
|
|
77
119
|
try {
|
|
@@ -151,3 +193,40 @@ async function callBilling(env, method, path, body) {
|
|
|
151
193
|
async function callSkills(env, method, path, body) {
|
|
152
194
|
return callService(getSkillsUrl(env), env, method, path, body);
|
|
153
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Resolve a user's id by email via user-auth's internal lookup endpoint
|
|
198
|
+
* (POST /api/v1/internal/users/lookup). cn-prod only: AWS envs resolve via
|
|
199
|
+
* the RDS SSH tunnel (db-utils resolveUserId) and their dev-skills clients
|
|
200
|
+
* don't carry the internal:users:write scope this endpoint requires.
|
|
201
|
+
*/
|
|
202
|
+
async function resolveUserIdByEmail(env, email) {
|
|
203
|
+
console.log(`Looking up user by email: ${email}`);
|
|
204
|
+
const token = getServiceToken(env);
|
|
205
|
+
const authUrl = USER_AUTH_URLS[env];
|
|
206
|
+
if (!authUrl)
|
|
207
|
+
throw new Error(`Unknown env: ${env}`);
|
|
208
|
+
const res = await fetch(`${authUrl}/api/v1/internal/users/lookup`, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
211
|
+
body: JSON.stringify({ email }),
|
|
212
|
+
});
|
|
213
|
+
const text = await res.text();
|
|
214
|
+
if (res.status === 404) {
|
|
215
|
+
throw new Error(`User not found (${env}): ${email}`);
|
|
216
|
+
}
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
throw new Error(formatServiceError(res.status, res.statusText, text));
|
|
219
|
+
}
|
|
220
|
+
let parsed;
|
|
221
|
+
try {
|
|
222
|
+
parsed = JSON.parse(text);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
throw new Error(`user-auth lookup returned non-JSON 2xx body: ${text.slice(0, 200)}`);
|
|
226
|
+
}
|
|
227
|
+
if (!parsed.user_id) {
|
|
228
|
+
throw new Error(`user-auth lookup response missing user_id: ${text.slice(0, 200)}`);
|
|
229
|
+
}
|
|
230
|
+
console.log(`✓ Found user: ${parsed.user_id}`);
|
|
231
|
+
return parsed.user_id;
|
|
232
|
+
}
|