浏览代码

fix: STS 接口修正 - /api/storage/credentials 不存在(404), 改用权威接口 /api/apig/deploy/huaweicloud (sessionToken+projectId→项目隔离STS); 修 obsutil 临时配置注入方式(环境变量不生效改 -config 参数, 曾致 NoSuchBucket); 生产实测 200 全通

ryanemax 1 周之前
父节点
当前提交
82afeaadd9
共有 3 个文件被更改,包括 49 次插入82 次删除
  1. 9 2
      README.md
  2. 4 3
      skills/fmode-storage/SKILL.md
  3. 36 77
      skills/fmode-storage/scripts/uploader.mjs

+ 9 - 2
README.md

@@ -37,10 +37,17 @@ cp -r skill-storage/skills/fmode-storage <你的工具技能目录>/fmode-storag
 第0级(自举): sessionToken(FMODE_SESSION_TOKEN 环境变量
                               或 ~/.fmode/config.json 的 sessionToken
                               或 ~/.fmode/config/user.json)
-             → POST https://server.fmode.cn/api/storage/credentials
-             → STS 临时凭证 {AK, SK, SecurityToken}(作用域限定用户 prefix,短时)
+              + storageProjectId(FMODE_STORAGE_PROJECT_ID 环境变量
+                              或 ~/.fmode/config.json 的 storageProjectId
+                              或 ./.fmode/deploy.json 的 projectId;
+                              须为本人有权限的 Project objectId)
+             → POST https://server.fmode.cn/api/apig/deploy/huaweicloud
+               body {token, projectId}
+             → STS 临时凭证 {accessKey, secretKey, securityToken, obsPath}
+               obsPath = obs://nova-cloud/dev/<projectId>/(项目隔离前缀)
              → 一次性 obsutil 临时配置直传 OBS(STS 不落盘、不进日志,用完即删)
              ✅ 登录 FMODE Studio 即可上传,无需预配任何 OBS 密钥
+             ⚠️ 旧设计 /api/storage/credentials 端点不存在(404),勿用
 第1级: 环境变量 FMODE_OBS_CONF(OBS util 配置文件路径)
 第2级: ~/.fmode/config.json → obsUtilPath / obsBucket / obsEndpoint / cdnDomain
 第3级: 项目 ./.fmode/config.json → 同上

+ 4 - 3
skills/fmode-storage/SKILL.md

@@ -23,9 +23,10 @@ node <skill_dir>/scripts/uploader.mjs setacl --key reports/ --acl public-read -r
 
 ```
 ┌─ 第0级(自举,推荐)──────────────────────────────────────────┐
-│ FMODE_SESSION_TOKEN 或 ~/.fmode/config.json 的 sessionToken    │
-│   → POST https://server.fmode.cn/api/storage/credentials      │
-│   → STS 临时凭证 {AK, SK, SecurityToken}(限用户 prefix,短时)│
+│ FMODE_SESSION_TOKEN + storageProjectId                        │
+│   → POST https://server.fmode.cn/api/apig/deploy/huaweicloud  │
+│   → STS 临时凭证 {accessKey, secretKey, securityToken, obsPath}│
+│     obsPath = obs://nova-cloud/dev/<projectId>/(项目隔离)   │
 │   → 一次性 obsutil 临时配置直传 OBS(命令结束即删,不落盘)    │
 └───────────────────────────────────────────────────────────────┘
 ┌─ 回落(自建 OBS / 已有 obsutil config 的用户)────────────────┐

+ 36 - 77
skills/fmode-storage/scripts/uploader.mjs

@@ -3,7 +3,7 @@
  * fmode-storage uploader — 对象存储上传/公开链接/ACL
  * 零依赖(Node ≥18)。凭据按 5 级优先级自动解析,永不入库。
  *
- * 第0级(自举):sessionToken → POST /api/storage/credentials 换 STS 临时凭证,
+ * 第0级(自举):sessionToken + projectId → POST /api/apig/deploy/huaweicloud 换 STS 临时凭证,
  *   内存持有直传 OBS(不落盘、不进日志)。后续级为回落链(自建 OBS / 已有
  *   obsutil config 的用户不受影响)。
  *
@@ -21,11 +21,12 @@ const HOME = os.homedir();
 
 // fmode 网关基址(签发 STS / 下载 URL)
 const FMODE_API_BASE = process.env.FMODE_API_BASE || 'https://server.fmode.cn';
-// 自举 v2(权威,2026-09 实测可用):sessionToken + projectId → STS
-// 见 fmode-studio rules/09 §4.3.4 与 future-server api/api-ncloud/apig/routes-deploy.js
+// 自举(权威,2026-09-12 生产实测 200):sessionToken + projectId → STS
+// POST /api/apig/deploy/huaweicloud → {accessKey, secretKey, securityToken, obsPath}
+// obsPath 形如 obs://nova-cloud/dev/<projectId>/(bucket=nova-cloud, prefix=dev/<projectId>/)
+// 服务端权限链: token→用户身份 → Project.user/owner 或 ProjectTeam 三查 → 华为云委托 Agency 签发 STS
+// 注意: 旧设计中的 POST /api/storage/credentials 端点在生产**不存在**(Cannot POST 404),已移除。
 const DEPLOY_STS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/apig/deploy/huaweicloud`;
-// 自举 v1(历史设计,端点可能未部署;失败自动回落 v2)
-const STORAGE_CREDENTIALS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/storage/credentials`;
 
 /**
  * 解析 storage 专用 projectId(非密钥)。
@@ -79,13 +80,14 @@ export function resolveSessionToken() {
 }
 
 /**
- * 第0级:sessionToken → STS 临时凭证(AK/SK/SecurityToken,作用域限定用户 prefix)。
+ * 第0级:sessionToken + projectId → STS 临时凭证(AK/SK/SecurityToken,作用域限定项目 prefix)。
  *
- * 权威链路(fmode-studio/docs/obs-cdn/02-架构与路径划分.md §5.2):
- *   sessionToken → POST /api/storage/credentials
- *                → { accessKeyId, secretAccessKey, sessionToken(即 SecurityToken),
- *                    endpoint, bucket, prefix, expiration }
+ * 权威链路(future-server api/api-ncloud/apig/routes-deploy.js,2026-09-12 生产实测 200):
+ *   POST /api/apig/deploy/huaweicloud {token, projectId}
+ *                → { code:200, data:{ accessKey, secretKey, securityToken, obsPath } }
+ *                → obsPath "obs://nova-cloud/dev/<projectId>/" 解析出 bucket/prefix
  *                → 客户端直传 OBS
+ * 服务端权限:token→用户身份 → Project.user / Project.owner / ProjectTeam 三查。
  *
  * ⚠️ 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
  *
@@ -93,42 +95,7 @@ export function resolveSessionToken() {
  *           endpoint: string, bucket: string, prefix: string } | null>}
  *          签发失败返回 null(调用方回落第1-4级)。
  */
-export async function fetchStsCredentials(sessionToken) {
-  if (!sessionToken) return null;
-  try {
-    const res = await fetch(STORAGE_CREDENTIALS_URL, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        'Authorization': `Bearer ${sessionToken}`,
-      },
-      body: JSON.stringify({ op: ['put', 'delete'] }),
-      signal: AbortSignal.timeout(15000),
-    });
-    if (!res.ok) return null;
-    const body = await res.json().catch(() => null);
-    const cred = body && (body.credentials || body.data);
-    if (!cred || !cred.accessKeyId || !cred.secretAccessKey) return null;
-    return {
-      ak: cred.accessKeyId,
-      sk: cred.secretAccessKey,
-      securityToken: cred.sessionToken || cred.securityToken || '',
-      endpoint: cred.endpoint || 'obs.cn-south-1.myhuaweicloud.com',
-      bucket: cred.bucket || null,
-      prefix: cred.prefix || '',
-    };
-  } catch { /* 网络/签名失败一律回落,不泄露错误细节 */ }
-  return null;
-}
-
-/**
- * 自举 v2(权威链路):sessionToken + projectId → POST /api/apig/deploy/huaweicloud → STS。
- * 服务端用平台自己的华为云授权签发项目隔离短时 STS,目标固定为
- *   obs://nova-cloud/dev/<projectId>/(bucket=nova-cloud, prefix=dev/<projectId>/)。
- * 返回字段映射:data.accessKey→ak, data.secretKey→sk, data.securityToken→securityToken,
- * data.obsPath→bucket/prefix 解析。成功返回对象,失败返回 null(调用方回落)。
- */
-export async function fetchStsViaDeploy(sessionToken, projectId) {
+export async function fetchStsCredentials(sessionToken, projectId) {
   if (!sessionToken || !projectId) return null;
   try {
     const res = await fetch(DEPLOY_STS_URL, {
@@ -141,19 +108,18 @@ export async function fetchStsViaDeploy(sessionToken, projectId) {
     const body = await res.json().catch(() => null);
     const data = body && body.data;
     if (!body || body.code !== 200 || !data || !data.success) return null;
-    const obsPath = String(data.obsPath || '');           // 例: obs://nova-cloud/dev/<projectId>/
+    const obsPath = String(data.obsPath || '');
     const m = obsPath.match(/^obs:\/\/([^/]+)\/(.*)\/$/);
     return {
       ak: String(data.accessKey || ''),
       sk: String(data.secretKey || ''),
       securityToken: String(data.securityToken || ''),
       expiresAt: data.expiresAt || null,
-      endpoint: 'obs.cn-south-1.myhuaweicloud.com',
+      endpoint: 'obs.cn-south-1.myhuaweicloud.com',   // nova-cloud 桶在 cn-south-1(实测 2026-09-12; north-4 会 NoSuchBucket)
       bucket: m ? m[1] : 'nova-cloud',
-      prefix: m ? m[2] : '',
-      projectId,
+      prefix: m ? m[2] + '/' : `dev/${projectId}/`,
     };
-  } catch { /* 网络失败一律回落,不泄露错误细节 */ }
+  } catch { /* 网络/签名失败一律回落,不泄露错误细节 */ }
   return null;
 }
 
@@ -177,11 +143,11 @@ export function runWithSts(sts, args, timeout = 300000) {
     '',
   ].join('\n'), { mode: 0o600 });
   try {
-    const r = spawnSync('obsutil', args, {
-      encoding: 'utf8',
-      timeout,
-      env: { ...process.env, OBSUTIL_CONFIG_FILE: cfgFile },
-    });
+    // obsutil 只认 -config=<file> 参数(不认 OBSUTIL_CONFIG_FILE 环境变量——
+    // 2026-09-12 实测:环境变量方式会静默回落默认配置导致 NoSuchBucket)。
+    // 桶级命令在位置参数后追加 -config;endpoint 已写进 config 文件。
+    const withCfg = [...args, '-config', cfgFile];
+    const r = spawnSync('obsutil', withCfg, { encoding: 'utf8', timeout });
     const out = (r.stdout || '') + (r.stderr || '');
     return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
   } finally {
@@ -199,32 +165,25 @@ export async function resolveStorageConfig() {
     sts: null,       // 第0级自举得到的 STS(内存持有)
     via: null,       // 命中的解析级别(诊断用,不含任何密钥)
   };
-  // ---- 第0级自举 v2(权威):sessionToken + projectId → deploy/huaweicloud → STS ----
+  // ---- 第0级自举(权威):sessionToken + projectId → deploy/huaweicloud → STS ----
   const sessionToken = resolveSessionToken();
   if (sessionToken) {
     const projectId = resolveStorageProjectId();
-    if (projectId) {
-      const sts = await fetchStsViaDeploy(sessionToken, projectId);
-      if (sts) {
-        cfg.sts = sts;
-        cfg.bucket = sts.bucket || cfg.bucket;
-        cfg.endpoint = sts.endpoint || cfg.endpoint;
-        cfg.cdnDomain = process.env.FMODE_CDN_DOMAIN || 'app.fmode.cn';
-        cfg.via = 'level0v2:sessionToken+projectId->deploySTS';
-        return cfg; // 自举成功,无需回落
-      }
+    const sts = projectId ? await fetchStsCredentials(sessionToken, projectId) : null;
+    if (sts) {
+      cfg.sts = sts;
+      cfg.bucket = sts.bucket || cfg.bucket;
+      cfg.endpoint = sts.endpoint || cfg.endpoint;
+      cfg.cdnDomain = process.env.FMODE_CDN_DOMAIN || 'app.fmode.cn';
+      cfg.via = 'level0:sessionToken+projectId->deploySTS';
+      return cfg; // 自举成功,无需回落
     }
-    // v2 未命中(无 projectId 或签发失败)→ v1 历史端点
-    const sts1 = await fetchStsCredentials(sessionToken);
-    if (sts1) {
-      cfg.sts = sts1;
-      cfg.bucket = sts1.bucket || cfg.bucket;
-      cfg.endpoint = sts1.endpoint || cfg.endpoint;
-      cfg.via = 'level0v1:sessionToken->STS';
-      return cfg;
+    // 自举未命中:给出可操作的明确指引
+    if (!projectId) {
+      console.error('sessionToken 存在但缺少 storageProjectId——请设置 FMODE_STORAGE_PROJECT_ID 或在 ~/.fmode/config.json 配 storageProjectId(须为本人有权限的 Project objectId)');
+    } else {
+      console.error('sessionToken 存在但 STS 签发失败(/api/apig/deploy/huaweicloud 未 200)——token 失效或该 projectId 不属于当前用户(服务端校验 Project.user/owner/ProjectTeam 三查)');
     }
-    // sessionToken 存在但两条链路都失败:给出明确报错指向重新登录
-    console.error('sessionToken 存在但 STS 换取失败(v2 deploy 与 v1 credentials 均未命中)——sessionToken 失效或缺少 storageProjectId 配置');
     console.error('(将继续尝试第1-4级回落配置)');
   }
   // ---- 第1级:环境变量 ----