@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.
@@ -1,6 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { getInfisicalConfig, getInfisicalToken, resolveUserId, connectBillingDB, escapeSQL } from './db-utils';
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 = 'pro';
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
- if (!['trial', 'starter', 'pro', 'enterprise'].includes(plan)) {
29
- console.error(`Unknown plan: ${plan}. Available: trial, starter, pro, enterprise`);
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
- const userId = await resolveUserId(email, env, infisicalConfig, token);
46
- const billing = await connectBillingDB(env, infisicalConfig, token);
47
- const bq = billing.query;
48
-
49
- // Read plan config from DB
50
- console.log(`Loading plan config: ${plan}`);
51
- const planRow = bq(`SELECT name, monthly_credits, session_token_limit, weekly_token_limit FROM plans WHERE id='${escapeSQL(plan)}'`);
52
- if (!planRow) { console.error(`❌ Plan not found in DB: ${plan}`); process.exit(1); }
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
- console.log(`\n✅ Done! ${email} now has ${planName} plan until ${periodEnd.toLocaleDateString()}\n`);
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 => {
@@ -2,14 +2,7 @@
2
2
 
3
3
  import { execSync } from 'child_process';
4
4
  import * as fs from 'fs';
5
- import { setupTunnel } from './db-utils';
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
- function parseDatabaseUrl(url: string): { user: string; password: string; host: string; port: number; database: string } {
101
- // postgresql://user:password@host:port/database?params
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 = environment === 'stage' ? 15432 : 15433;
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);
@@ -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
- function getGitHubVariable(name: string): string {
48
- return execSync(`gh variable get ${name} -R Optima-Chat/optima-dev-skills`, { encoding: 'utf-8' }).trim();
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
- const clientId = (0, infisical_secrets_1.fetchInfisicalSecret)(env, DEV_SKILLS_OAUTH_PATH, DEV_SKILLS_CLIENT_ID_KEY, cfg, tok);
70
- const clientSecret = (0, infisical_secrets_1.fetchInfisicalSecret)(env, DEV_SKILLS_OAUTH_PATH, DEV_SKILLS_CLIENT_SECRET_KEY, cfg, tok);
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 body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`;
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
+ }