Skip to content

fix: keys:reveal + redirect fallback test + bill-on-failure toggle#1149

Merged
ding113 merged 3 commits into
devfrom
fix/key-reveal-redirect-fallback-bill-toggle
May 2, 2026
Merged

fix: keys:reveal + redirect fallback test + bill-on-failure toggle#1149
ding113 merged 3 commits into
devfrom
fix/key-reveal-redirect-fallback-bill-toggle

Conversation

@ding113

@ding113 ding113 commented May 2, 2026

Copy link
Copy Markdown
Owner

Summary

Four fixes bundled into one PR against dev:

  1. keys:reveal endpoint — User-management page key reveal/copy was missing for the cases where the key was not pre-loaded in the list payload. Mirrors the provider key:reveal pattern (audit log, no-store cache, admin-only) and rewires the dashboard to fetch on click. Side benefit: unmasked keys are no longer shipped in user-list payloads.

  2. Model redirect fallback regression test — User reported that fallback may carry over the previous provider's redirected model. Static analysis + 4 new scenarios confirm the existing ModelRedirector.apply already implements the correct contract (each provider matches against the user's original model). Test file pins the contract to catch any future regression.

  3. 499 frequency investigation — Audited the recent abort-listener cleanup commits (bcba5d0d, 8968a426). They only fixed listener leaks in normal-completion paths and do not change abort detection. The 499 in the user's log is a genuine Codex CLI mid-stream cancellation. No code change required; the new bill-on-failure toggle below recovers token billing in these cases.

  4. billNonSuccessfulRequests system setting — New toggle (default OFF). When ON, non-2xx responses (e.g., 499 client abort) that already received positive token usage from the upstream are billed by usage. The existing fake-200 detector remains in place — fake-success error payloads are never billed regardless of toggle state.

Files

  • Schema: src/drizzle/schema.ts + new migration drizzle/0102_useful_lionheart.sql
  • API: GET /api/v1/keys/{id}:revealrouter.ts, handlers.ts, keys.ts schema, action-migration matrix
  • Action: getUnmaskedKey in src/actions/keys.ts (mirrors getUnmaskedProviderKey)
  • UI rewire: key-row-item.tsx, key-list.tsx, user-key-table-row.tsx — fetch on click; users.ts no longer ships fullKey
  • Settings UI: system-settings-form.tsx + 5-locale i18n strings
  • Cost gate: src/app/v1/_lib/proxy/response-handler.ts:resolveBillableUsageMetricsForCost (now exported for tests)

Tests

  • tests/unit/actions/keys-reveal.test.ts — admin/auth/404/audit-redaction (4 cases)
  • tests/unit/proxy/model-redirect-fallback.test.ts — original-model match, redirected-name no-match, no-rules reset, distinct targets (4 cases)
  • tests/unit/proxy/response-handler-bill-non-success.test.ts — toggle off, toggle on, null/zero usage, cache-only tokens, fake-200 still skipped, setting-read fail-closed (7 cases)

Test plan

  • bun run typecheck clean
  • bun run lint clean
  • bun run build succeeds (production bundle)
  • bun run test — only 3 pre-existing failures remain (openai-image-compat, auth-middleware-api-key-admin, api-key-auth); all also fail on dev baseline. New tests all pass (15/15).
  • OpenAPI types regenerated (bun run openapi:generate)
  • Manual smoke (admin)
    • Click eye icon on a user key → dialog opens with full key; network shows GET /api/v1/keys/{id}:reveal{ key: "..." } with Cache-Control: no-store
    • Confirm fullKey no longer present in /api/v1/users/.../keys response
    • Configure two providers with redirect rules and a forced fallback; observe Provider B receives the original model (or its own redirect target), never Provider A's
    • Toggle billNonSuccessfulRequests on; abort a streaming request mid-flight; confirm DB row has cost > 0 (toggle off → cost 0)
    • Repeat with a fake-200 error payload — must remain unbilled regardless

Notes

  • Migration uses IF NOT EXISTS for idempotency.
  • Setting cache (1-min TTL) is invalidated on save like other settings.
  • The cost-gate change in response-handler.ts fails closed on Redis/DB read errors (defaults to skip billing).
  • 499 investigation findings: no proxy regression — recent abort-cleanup PRs only fix memory leaks; client-cancellation detection is unchanged.

🤖 Generated with Claude Code

Greptile Summary

This PR bundles four changes: a GET /api/v1/keys/{id}:reveal endpoint that lets admins and key owners fetch the unmasked key on demand (eliminating fullKey from list payloads); a billNonSuccessfulRequests system setting that gates billing on non-2xx responses carrying positive upstream token usage; regression tests for model-redirect fallback; and the 499 investigation write-up. The implementation follows existing patterns closely (fallback cascade, audit redaction, no-store cache header), and all 15 new tests pass.

Confidence Score: 5/5

Safe to merge — one minor OpenAPI description inaccuracy, no logic or security defects.

All findings are P2 (documentation wording). Core logic — auth ownership check, audit redaction, fail-closed billing toggle, and multi-level DB fallback cascade — is correct and well-tested across 15 new passing test cases.

src/lib/api/v1/schemas/keys.ts — one-line description fix needed for the generated OpenAPI spec.

Important Files Changed

Filename Overview
src/lib/api/v1/schemas/keys.ts Adds KeyRevealResponseSchema; field description incorrectly limits access to 'admin callers' when key owners are also valid callers.
src/actions/keys.ts Adds getUnmaskedKey action — admin or owner access, audit log with key redaction, mirrors provider reveal pattern cleanly.
src/app/api/v1/resources/keys/router.ts New GET /keys/{keyId}:reveal route registered with requireAuth('read'); per-route ownership enforced at the action layer. Route + handler wiring look correct.
src/app/v1/_lib/proxy/response-handler.ts Adds billNonSuccessfulRequests gate before fake-200 check; fails-closed on cache read errors; exports function for unit testing. Logic is sound.
src/repository/system-config.ts Adds billNonSuccessfulRequests to both SELECT and UPDATE paths with correct multi-level fallback cascade; if (!updated) guard added to skip redundant fallback attempts.
src/app/[locale]/dashboard/_components/user/key-list.tsx Rewires reveal/copy to lazy-fetch via :reveal endpoint; adds toast errors, disable state during fetch, and cache invalidation on key-set change.
src/app/[locale]/dashboard/_components/user/key-row-item.tsx Replaces fullKey prop with on-demand fetchUnmaskedKey; cache invalidated on keyData.id/maskedKey change; toast on failure; isRevealing disables buttons correctly.
src/actions/users.ts Replaces fullKey with canReveal flag in all three user-list code paths — unmasked keys no longer included in list payloads.
drizzle/0102_useful_lionheart.sql Idempotent IF NOT EXISTS migration adds bill_non_successful_requests boolean NOT NULL DEFAULT false.
tests/unit/proxy/response-handler-bill-non-success.test.ts 7 test cases covering toggle OFF/ON, null/zero usage, cache-only tokens, fake-200 protection, and fail-closed on settings read error.
tests/unit/actions/keys-reveal.test.ts 5 cases: admin reveal, owner reveal, cross-owner rejection, unauthenticated rejection, 404 — audit redaction also asserted.

Sequence Diagram

sequenceDiagram
    participant UI as Dashboard UI
    participant REST as GET /api/v1/keys/{id}:reveal
    participant Action as getUnmaskedKey action
    participant DB as Database
    participant Audit as Audit Log

    UI->>REST: GET (requireAuth "read")
    REST->>Action: callAction(keyId)
    Action->>DB: findKeyById(keyId)
    DB-->>Action: key row
    Action->>Action: isAdmin OR isOwner?
    alt Not authorized
        Action-->>REST: {ok:false, error:"无权限"}
        REST-->>UI: 403
    else Authorized
        Action->>Audit: emitActionAudit (key value redacted)
        Action-->>REST: {ok:true, data:{key:"sk-..."}}
        REST-->>UI: 200 Cache-Control:no-store
    end
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/lib/api/v1/schemas/keys.ts:133
**Misleading schema description — key owners also receive this response**

The `describe` text says "Returned only to admin callers", but both the route comment and the action implementation allow any key **owner** to call this endpoint. The description will appear verbatim in the generated OpenAPI spec and client docs, causing API consumers to believe they need admin credentials when they only need to own the key.

Reviews (3): Last reviewed commit: "fix(pr-review): align keys:reveal x-requ..." | Re-trigger Greptile

…-failure toggle

Bundles four fixes raised against dev:

1. **keys:reveal endpoint (Issue #1)** — Add `GET /api/v1/keys/{id}:reveal`
   mirroring the provider key:reveal pattern (audit log, no-store cache,
   admin-only). Rewire dashboard key row + key list to fetch the unmasked
   key on click instead of pre-loading it in user-list payloads. Removes
   `fullKey` from `UserKeyDisplay` and replaces it with a `canReveal`
   permission flag — unmasked keys never leave the server unless explicitly
   requested.

2. **Model redirect fallback regression test (Issue #2)** — Pin the
   contract: when fallback occurs, the next provider's redirect rules MUST
   match against the user-requested model (not the model the previous
   provider rewrote it to). Static analysis + 4 new test scenarios confirm
   the existing `ModelRedirector.apply` already implements the contract.
   Test file now guards against future regression.

3. **499 frequency investigation (Issue #3)** — Read-only audit of the
   recent abort-listener cleanup commits (bcba5d0, 8968a42). Conclusion:
   no regression — those commits only fixed listener leaks in normal-
   completion paths. The user's log (Codex CLI cancelling stream at ~3s)
   is genuine and correctly classified as 499. No code change for this
   issue; the new bill-on-failure toggle (#4) gives operators a way to
   recover token billing for these cases.

4. **billNonSuccessfulRequests toggle (Issue #4)** — New system setting
   (default OFF). When ON, requests with non-2xx status that received
   positive token usage from the upstream (e.g., 499 mid-stream client
   abort with partial usage) are billed normally. The existing fake-200
   detector still skips billing for fake-success error payloads
   regardless of toggle state. Schema migration + cache + UI toggle (5
   locales) + unit tests.

Tests added:
- `tests/unit/actions/keys-reveal.test.ts` — admin/auth/404 paths and
  audit redaction for the new action.
- `tests/unit/proxy/model-redirect-fallback.test.ts` — 4 scenarios for
  fallback redirect behavior.
- `tests/unit/proxy/response-handler-bill-non-success.test.ts` — toggle
  semantics, fake-200 still skipped, setting-read failure fail-closed.

Migration: 0102_useful_lionheart.sql adds
`bill_non_successful_requests boolean default false not null` to
`system_settings` (idempotent ADD COLUMN IF NOT EXISTS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 2, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

本 PR 包含两条相互独立的改动:1) 引入按需揭示 API 密钥的后端与前端流程 — 新增 GET /api/v1/keys/{keyId}:reveal、对应 action、路由、客户端与 UI 变更,列表项不再返回明文密钥;2) 添加 billNonSuccessfulRequests 系统设置及其端到端支撑(DB migration、schema、缓存、类型、验证、仓储、设置 UI 与计费决策逻辑),用于在上游报告正向 token 用量时对非2xx 响应进行计费。

Changes

API 密钥揭示功能

Layer / File(s) Summary
数据模型与类型
src/types/user.ts, src/lib/api/v1/schemas/keys.ts
UserKeyDisplay 移除 fullKey,新增 canReveal: boolean;新增 KeyRevealResponseSchema
服务端操作
src/actions/keys.ts
新增导出 action getUnmaskedKey(keyId):权限校验(admin 或 owner)、查询、审计(key.key_reveal,成功含 redactExtraKeys: ["key"])并返回明文密钥或错误。
路由与处理
src/app/api/v1/resources/keys/handlers.ts, src/app/api/v1/resources/keys/router.ts
新增 revealKey handler 与 GET /keys/{keyId}:reveal 路由,parseKeyParams 支持 :reveal 后缀,路由使用 requireAuth("read") 并在响应上设置 no-store headers。
API 客户端与 OpenAPI
src/lib/api-client/v1/actions/keys.ts, src/lib/api-client/v1/openapi-types.gen.ts, src/app/api/actions/[...route]/route.ts
新增客户端函数 getUnmaskedKey(keyId);OpenAPI 类型/路径新增 getKeysByKeyidReveal;用户 key 列表响应类型由 fullKey 更改为 canReveal
用户数据产生端
src/actions/users.ts, src/app/api/actions/[...route]/route.ts
getUsers/getUsersBatch/getUsersBatchCore 中不再返回 fullKey,改为返回 canReveal: boolean
前端 UI 与交互
src/app/[locale]/dashboard/_components/user/key-list.tsx, .../key-row-item.tsx, .../user-key-table-row.tsx, src/app/[locale]/dashboard/users/users-page-client.tsx
列表/行组件按需调用 getUnmaskedKey 并缓存已揭示密钥、追踪加载状态、在可用剪贴板环境下异步复制;搜索不再匹配 fullKey
审计与国际化
messages/*/auditLogs.json
actions.key.key_reveal 添加多语言标签(en/ja/ru/zh-CN/zh-TW)。
测试
tests/unit/actions/keys-reveal.test.ts
新增单元测试覆盖管理员/拥有者成功、权限拒绝、未认证、密钥不存在等场景;断言审计事件含 redact 配置 且未泄露原文。

非成功请求计费功能

Layer / File(s) Summary
数据库迁移与 Schema
drizzle/0102_useful_lionheart.sql, drizzle/meta/_journal.json, src/drizzle/schema.ts
新增数据库列 system_settings.bill_non_successful_requests(boolean, NOT NULL, DEFAULT false),Drizzle schema 同步新增 billNonSuccessfulRequests 字段。
类型与验证
src/types/system-config.ts, src/lib/validation/schemas.ts, src/lib/api/v1/schemas/system-config.ts
SystemSettingsUpdateSystemSettingsInput 增加 billNonSuccessfulRequests 字段;验证与 API schema 更新。
仓储与变换
src/repository/system-config.ts, src/repository/_shared/transformers.ts
getSystemSettings/updateSystemSettings 将新列纳入 select/update/returning,新增对列缺失的第一层降级(省略新列并重试);toSystemSettings 映射新增字段;createFallbackSettings 默认 false。
缓存默认值
src/lib/config/system-settings-cache.ts
在 DB 读取失败且无缓存时的默认设置对象中显式包含 billNonSuccessfulRequests: false
计费决策逻辑
src/app/v1/_lib/proxy/response-handler.ts
导出并修改 resolveBillableUsageMetricsForCost:对非2xx 响应读取缓存设置 billNonSuccessfulRequests,仅当开启且 hasPositiveBillableTokens(usage) 为真时才返回可计费的 usage;添加 hasPositiveBillableTokens 辅助函数。
设置 UI 与提交
src/app/[locale]/settings/config/_components/system-settings-form.tsx, src/app/[locale]/settings/config/page.tsx, src/actions/system-config.ts
设置表单新增切换项(含 tooltip 与图标),initialSettings/本地 state/保存请求中均包含并提交 billNonSuccessfulRequestssaveSystemSettings 接受并写入该字段。
国际化
messages/*/settings/config.json
为设置界面添加 billNonSuccessfulRequestsbillNonSuccessfulRequestsDescbillNonSuccessfulRequestsTooltip 的多语言文案(en/ja/ru/zh-CN/zh-TW)。
客户端类型/迁移映射
src/lib/api/v1/action-migration-matrix.ts, src/lib/api-client/v1/openapi-types.gen.ts
OpenAPI 类型中新增 billNonSuccessfulRequests 字段;迁移矩阵格式化调整。
测试
tests/unit/proxy/response-handler-bill-non-success.test.ts, tests/unit/proxy/model-redirect-fallback.test.ts
新增针对 billNonSuccessfulRequests 切换的测试覆盖(不同 status、usage 组合、fake-200 行为、系统设置读取失败等),并新增 model-redirect fallback 回归测试。

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed 标题准确总结了变更集的主要内容:三个功能修复(keys:reveal端点、重定向回退测试、非成功请求计费开关)和安全改进。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description clearly relates to the changeset, detailing four bundled fixes with specific file references and implementation details.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/key-reveal-redirect-fallback-bill-toggle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added bug Something isn't working area:UI area:core size/XL Extra Large PR (> 1000 lines) labels May 2, 2026
Comment on lines +82 to +92
try {
const res = await getUnmaskedKey(keyId);
if (!res.ok || !res.data?.key) return null;
setRevealedKeys((prev) => ({ ...prev, [keyId]: res.data.key }));
return res.data.key;
} finally {
setRevealingKeyId(null);
}
};

const success = await copyToClipboard(key.fullKey);
const handleCopyKey = async (key: UserKeyDisplay) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent failure on key reveal in key-list

fetchUnmaskedKey returns null on API errors without giving the user any feedback — handleCopyKey and handleToggleVisibility both silently bail out. The parallel implementation in key-row-item.tsx correctly shows a toast.error(...) on failure. Without feedback, clicking the copy/reveal button will appear to do nothing when the request fails.

Suggested change
try {
const res = await getUnmaskedKey(keyId);
if (!res.ok || !res.data?.key) return null;
setRevealedKeys((prev) => ({ ...prev, [keyId]: res.data.key }));
return res.data.key;
} finally {
setRevealingKeyId(null);
}
};
const success = await copyToClipboard(key.fullKey);
const handleCopyKey = async (key: UserKeyDisplay) => {
const fetchUnmaskedKey = async (keyId: number): Promise<string | null> => {
if (revealedKeys[keyId]) return revealedKeys[keyId];
setRevealingKeyId(keyId);
try {
const res = await getUnmaskedKey(keyId);
if (!res.ok || !res.data?.key) {
toast.error((res as { ok: false; error?: string }).error ?? t("copyKeyFailed"));
return null;
}
setRevealedKeys((prev) => ({ ...prev, [keyId]: res.data.key }));
return res.data.key;
} catch (err) {
console.error("[KeyList] reveal failed", err);
toast.error(t("copyKeyFailed"));
return null;
} finally {
setRevealingKeyId(null);
}
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/dashboard/_components/user/key-list.tsx
Line: 82-92

Comment:
**Silent failure on key reveal in key-list**

`fetchUnmaskedKey` returns `null` on API errors without giving the user any feedback — `handleCopyKey` and `handleToggleVisibility` both silently bail out. The parallel implementation in `key-row-item.tsx` correctly shows a `toast.error(...)` on failure. Without feedback, clicking the copy/reveal button will appear to do nothing when the request fails.

```suggestion
  const fetchUnmaskedKey = async (keyId: number): Promise<string | null> => {
    if (revealedKeys[keyId]) return revealedKeys[keyId];
    setRevealingKeyId(keyId);
    try {
      const res = await getUnmaskedKey(keyId);
      if (!res.ok || !res.data?.key) {
        toast.error((res as { ok: false; error?: string }).error ?? t("copyKeyFailed"));
        return null;
      }
      setRevealedKeys((prev) => ({ ...prev, [keyId]: res.data.key }));
      return res.data.key;
    } catch (err) {
      console.error("[KeyList] reveal failed", err);
      toast.error(t("copyKeyFailed"));
      return null;
    } finally {
      setRevealingKeyId(null);
    }
  };
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +411 to +420
let allowBillingNonSuccess = false;
try {
const settings = await getCachedSystemSettings();
allowBillingNonSuccess = settings.billNonSuccessfulRequests === true;
} catch (error) {
logger.warn(
"[CostCalculation] Failed to read billNonSuccessfulRequests setting, defaulting to skip",
{ error: error instanceof Error ? error.message : String(error) }
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Settings cache read on every non-2xx response

getCachedSystemSettings() is awaited for every non-2xx response even when the feature will almost always remain OFF. While the 1-minute cache prevents database trips, the added promise chain still runs on every client abort (499), rate-limit (429), etc. This is minor since the cache is cheap, but worth noting for high-abort-rate deployments.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/response-handler.ts
Line: 411-420

Comment:
**Settings cache read on every non-2xx response**

`getCachedSystemSettings()` is awaited for every non-2xx response even when the feature will almost always remain OFF. While the 1-minute cache prevents database trips, the added promise chain still runs on every client abort (499), rate-limit (429), etc. This is minor since the cache is cheap, but worth noting for high-abort-rate deployments.

How can I resolve this? If you propose a fix, please make it concise.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ac454171d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/actions/keys.ts Outdated
Comment on lines +838 to +839
if (!session || session.user.role !== "admin") {
return { ok: false, error: "权限不足:仅管理员可查看完整密钥" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Permit key owners to reveal their own key

This new admin-only guard regresses the existing self-service flow for non-admin users: getUsers still marks a user's own keys as revealable (canExposeFullKey returns true for session.user.id === targetUser.id in src/actions/users.ts), and the UI now relies on getUnmaskedKey to fetch the full value on click, but this check rejects every non-admin caller. As a result, regular users on /dashboard/users see reveal/copy controls for their own keys that now always fail.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented May 2, 2026

Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a security enhancement by removing full API keys from the initial list payload and implementing a lazy-loading mechanism via a new :reveal endpoint. It also adds a system setting to allow billing for non-successful requests (e.g., 499 client cancellations) when upstream token usage is reported. Feedback focuses on a regression where regular users can no longer reveal their own keys due to overly restrictive admin-only checks in the new action and router. Additionally, error handling in the UI should be improved to provide user feedback when the key retrieval fails.

Comment thread src/actions/keys.ts Outdated
Comment on lines +838 to +845
if (!session || session.user.role !== "admin") {
return { ok: false, error: "权限不足:仅管理员可查看完整密钥" };
}

const key = await findKeyById(keyId);
if (!key) {
return { ok: false, error: "密钥不存在" };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The getUnmaskedKey action currently restricts access to admins only. This prevents regular users from revealing or copying their own API keys in the dashboard, which is a regression since keys are no longer included in the initial list payload for security reasons. The permission check should be moved after findKeyById to allow both admins and the key owner to reveal the key. Additionally, the error messages are hardcoded in Chinese, which should be replaced with i18n keys or English to maintain consistency across locales.

Suggested change
if (!session || session.user.role !== "admin") {
return { ok: false, error: "权限不足:仅管理员可查看完整密钥" };
}
const key = await findKeyById(keyId);
if (!key) {
return { ok: false, error: "密钥不存在" };
}
const key = await findKeyById(keyId);
if (!key) {
return { ok: false, error: "Key not found" };
}
const session = await getSession();
if (!session || (session.user.role !== "admin" && session.user.id !== key.userId)) {
return { ok: false, error: "Permission denied" };
}

Comment thread src/app/api/v1/resources/keys/router.ts Outdated
Comment on lines +232 to +245
"x-required-access": "admin",
security,
request: { params: KeyIdParamSchema },
responses: {
200: {
description: "Unmasked key.",
content: { "application/json": { schema: KeyRevealResponseSchema } },
},
...problemResponses,
},
});

keysRouter.openAPIRegistry.registerPath(revealKeyRoute);
keysRouter.get("/keys/:keyId{[0-9]+:reveal}", requireAuth("admin"), revealKey);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The :reveal endpoint is currently restricted to admin at the router level. To allow users to reveal their own keys (as suggested in the action logic), this restriction should be relaxed to requireAuth(), allowing the action to perform the specific ownership check. The OpenAPI documentation should also be updated to reflect that owners can access this endpoint.

Suggested change
"x-required-access": "admin",
security,
request: { params: KeyIdParamSchema },
responses: {
200: {
description: "Unmasked key.",
content: { "application/json": { schema: KeyRevealResponseSchema } },
},
...problemResponses,
},
});
keysRouter.openAPIRegistry.registerPath(revealKeyRoute);
keysRouter.get("/keys/:keyId{[0-9]+:reveal}", requireAuth("admin"), revealKey);
"x-required-access": "admin | owner",
security,
request: { params: KeyIdParamSchema },
responses: {
200: {
description: "Unmasked key.",
content: { "application/json": { schema: KeyRevealResponseSchema } },
},
...problemResponses,
},
});
keysRouter.openAPIRegistry.registerPath(revealKeyRoute);
keysRouter.get("/keys/:keyId{[0-9]+:reveal}", requireAuth(), revealKey);

setRevealingKeyId(keyId);
try {
const res = await getUnmaskedKey(keyId);
if (!res.ok || !res.data?.key) return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When getUnmaskedKey fails, fetchUnmaskedKey returns null silently. This results in a poor user experience where clicking the reveal or copy button does nothing. Consider adding a toast notification to inform the user of the failure, similar to the implementation in KeyRowItem.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/actions/users.ts (1)

413-421: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

canReveal / canCopy 与 reveal 接口的真实权限保持一致。

这里复用了旧的 canExposeFullKey(...) 判定,但列表里已经不再下发 fullKey,前端接下来只能调用 GET /api/v1/keys/{id}:reveal。本 PR 又把这个 reveal 能力改成了管理员专用,所以普通用户查看自己的 key 时仍会收到 canReveal: true / canCopy: true,按钮会显示出来,但点击后只能得到权限错误。

建议把这两个字段改成与 reveal 端点一致的授权条件,或者在补齐非管理员 reveal 通路前不要暴露它们。

可参考的修正方向
-            const canUserManageKey = canExposeFullKey(session, user, isAdmin);
+            const canRevealKey = isAdmin;
             return {
               id: key.id,
               name: key.name,
               maskedKey: maskKey(key.key),
-              canReveal: canUserManageKey,
-              canCopy: canUserManageKey,
+              canReveal: canRevealKey,
+              canCopy: canRevealKey,

getUsersBatchgetUsersBatchCore 里的同类映射也需要一起收敛到同一套权限判断。

Also applies to: 737-744, 886-891

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/users.ts` around lines 413 - 421, The canReveal/canCopy flags are
computed with canExposeFullKey(...) but the frontend must reflect the actual
reveal endpoint permission; update the mapping in the users list (the block that
builds {id,name,maskedKey,canReveal,canCopy}) to use the same authorization
predicate the reveal handler uses (the exact reveal-check used by GET
/api/v1/keys/{id}:reveal) instead of canExposeFullKey, and apply the same change
to the analogous mappings in getUsersBatch and getUsersBatchCore so all lists
use the identical reveal permission logic (use session, user and the key
id/owner as the reveal-check inputs).
src/app/v1/_lib/proxy/response-handler.ts (1)

396-438: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

billNonSuccessfulRequests 还没有覆盖 finalizeRequestStats() 这条计费路径。

这里的新 gate 只保护了显式调用 resolveBillableUsageMetricsForCost() 的分支,但同文件里 finalizeRequestStats() 在已解析出 usageMetrics 时,仍会在 Line 3779 和 Line 3794 直接基于 normalizedUsage 更新 DB / Redis。Gemini passthrough 这些调用点走的正是这条路径,所以在 billNonSuccessfulRequests = false 时,非 2xx 依然会被计费。建议把 finalizeRequestStats() 的 usage 分支也先过一遍这个 resolver,再决定是否执行 updateRequestCostFromUsage()trackCostToRedis() 和 session cost 计算。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/v1/_lib/proxy/response-handler.ts` around lines 396 - 438, The
finalizeRequestStats() path currently updates costs directly from
normalizedUsage (calling updateRequestCostFromUsage(), trackCostToRedis() and
adjusting session cost) without consulting the new gate; ensure
finalizeRequestStats() first calls resolveBillableUsageMetricsForCost(session,
provider, normalizedUsage, statusCode, responseText) (or reuses its logic) and
only proceeds with updateRequestCostFromUsage(), trackCostToRedis(), and session
cost adjustments when that call returns a non-null UsageMetrics; also ensure the
same hasPositiveBillableTokens check and billNonSuccessfulRequests behavior is
applied so non-2xx requests are skipped when the setting is false.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/actions/keys.ts`:
- Around line 835-840: 当前不一致来自 getUnmaskedKey 对 reveal 强制仅 admin,而
canExposeFullKey (used to set canReveal/canCopy in the keys list) 仍允许“普通用户查看自己的
key”,导致列表显示入口但点击会 403;请在本 PR 中保持契约一致:修改 src/actions/users.ts 中的
canExposeFullKey(...)(及其在 keys 列表生成处)使其鉴权逻辑与 keys.getUnmaskedKey 相同(即仅 admin 返回
true),或者反向放宽 getUnmaskedKey,但二者必须一致——建议直接收紧 canExposeFullKey 到 admin-only 并更新生成
canReveal/canCopy 的代码以反映该变更。

In `@src/app/`[locale]/dashboard/_components/user/key-list.tsx:
- Around line 79-90: fetchUnmaskedKey currently returns null on failure and
callers (handleCopy/handleToggleVisibility) silently return, leaving the UI
appearing unresponsive; change fetchUnmaskedKey to surface a visible error
(e.g., set an error state or call the existing toast/notification helper) when
the API call fails or returns no key, and ensure it uses the i18n message keys
for copy/reveal errors; keep setting/clearing setRevealingKeyId and updating
revealedKeys as-is but add a localized visible notification before returning
null so callers no longer silently fail (apply the same pattern used around the
calls referenced at lines 92-95 and 103-110).

In `@src/app/`[locale]/dashboard/_components/user/key-row-item.tsx:
- Around line 209-235: The revealedKey cache can leak across different keys
because fetchUnmaskedKey only checks revealedKey and not which key it belongs
to; update the component to clear the cached revealedKey whenever the prop
keyData.id changes (e.g., add a useEffect that calls setRevealedKey(null) with
[keyData.id] as dependency) so fetchUnmaskedKey always fetches the correct
secret for the current key; ensure any related state like isRevealing is reset
or handled appropriately when keyData.id changes (use setIsRevealing(false) or
cancel outstanding requests if implemented).

In `@tests/unit/proxy/model-redirect-fallback.test.ts`:
- Around line 224-247: The test "Provider B without redirect rules resets
request.model to the original" only asserts session.request.model but misses
asserting session.request.message.model; update that test to also assert
session.request.message.model equals REQUESTED_MODEL after calling
ModelRedirector.apply(session, providerB) (this verifies resetToOriginal
correctly resets both session.request.model and session.request.message.model
when providerB has modelRedirects === null); reference the test name, the
ModelRedirector.apply call, and the resetToOriginal behavior to locate where to
add the extra assertion.

---

Outside diff comments:
In `@src/actions/users.ts`:
- Around line 413-421: The canReveal/canCopy flags are computed with
canExposeFullKey(...) but the frontend must reflect the actual reveal endpoint
permission; update the mapping in the users list (the block that builds
{id,name,maskedKey,canReveal,canCopy}) to use the same authorization predicate
the reveal handler uses (the exact reveal-check used by GET
/api/v1/keys/{id}:reveal) instead of canExposeFullKey, and apply the same change
to the analogous mappings in getUsersBatch and getUsersBatchCore so all lists
use the identical reveal permission logic (use session, user and the key
id/owner as the reveal-check inputs).

In `@src/app/v1/_lib/proxy/response-handler.ts`:
- Around line 396-438: The finalizeRequestStats() path currently updates costs
directly from normalizedUsage (calling updateRequestCostFromUsage(),
trackCostToRedis() and adjusting session cost) without consulting the new gate;
ensure finalizeRequestStats() first calls
resolveBillableUsageMetricsForCost(session, provider, normalizedUsage,
statusCode, responseText) (or reuses its logic) and only proceeds with
updateRequestCostFromUsage(), trackCostToRedis(), and session cost adjustments
when that call returns a non-null UsageMetrics; also ensure the same
hasPositiveBillableTokens check and billNonSuccessfulRequests behavior is
applied so non-2xx requests are skipped when the setting is false.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3f4eda3c-2014-4854-90d7-64beb266f3e1

📥 Commits

Reviewing files that changed from the base of the PR and between c99cf3e and 4ac4541.

📒 Files selected for processing (41)
  • drizzle/0102_useful_lionheart.sql
  • drizzle/meta/0102_snapshot.json
  • drizzle/meta/_journal.json
  • messages/en/auditLogs.json
  • messages/en/settings/config.json
  • messages/ja/auditLogs.json
  • messages/ja/settings/config.json
  • messages/ru/auditLogs.json
  • messages/ru/settings/config.json
  • messages/zh-CN/auditLogs.json
  • messages/zh-CN/settings/config.json
  • messages/zh-TW/auditLogs.json
  • messages/zh-TW/settings/config.json
  • src/actions/keys.ts
  • src/actions/system-config.ts
  • src/actions/users.ts
  • src/app/[locale]/dashboard/_components/user/key-list.tsx
  • src/app/[locale]/dashboard/_components/user/key-row-item.tsx
  • src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
  • src/app/[locale]/dashboard/users/users-page-client.tsx
  • src/app/[locale]/settings/config/_components/system-settings-form.tsx
  • src/app/[locale]/settings/config/page.tsx
  • src/app/api/actions/[...route]/route.ts
  • src/app/api/v1/resources/keys/handlers.ts
  • src/app/api/v1/resources/keys/router.ts
  • src/app/v1/_lib/proxy/response-handler.ts
  • src/drizzle/schema.ts
  • src/lib/api-client/v1/actions/keys.ts
  • src/lib/api-client/v1/openapi-types.gen.ts
  • src/lib/api/v1/action-migration-matrix.ts
  • src/lib/api/v1/schemas/keys.ts
  • src/lib/api/v1/schemas/system-config.ts
  • src/lib/config/system-settings-cache.ts
  • src/lib/validation/schemas.ts
  • src/repository/_shared/transformers.ts
  • src/repository/system-config.ts
  • src/types/system-config.ts
  • src/types/user.ts
  • tests/unit/actions/keys-reveal.test.ts
  • tests/unit/proxy/model-redirect-fallback.test.ts
  • tests/unit/proxy/response-handler-bill-non-success.test.ts
💤 Files with no reviewable changes (1)
  • src/app/[locale]/dashboard/users/users-page-client.tsx

Comment thread src/actions/keys.ts
Comment thread src/app/[locale]/dashboard/_components/user/key-list.tsx
Comment thread src/app/[locale]/dashboard/_components/user/key-row-item.tsx
Comment thread tests/unit/proxy/model-redirect-fallback.test.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] [ERROR-SILENT] Silent failure on key reveal with no user feedback

Why this is a problem: When the API call to :reveal fails, the function returns null without any user feedback. Users clicking copy/reveal will see the button appear to do nothing - a poor UX that looks like a bug.

Compare to the parallel implementation in key-row-item.tsx (lines 224-237) which correctly shows toast.error() on failure.

Suggested fix:
Add error handling with toast notification per CLAUDE.md i18n requirements. Follow the pattern in key-row-item.tsx:

  1. Add 'toast' import from 'sonner'
  2. Add 'useTranslations' import from 'next-intl'
  3. Add try/catch with toast.error(t("copyKeyFailed")) on failure

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This XL-sized PR bundles four related changes: (1) a new keys:reveal endpoint for secure on-demand key retrieval, (2) regression tests for model redirect fallback behavior, (3) a billNonSuccessfulRequests system setting toggle, and (4) UI rewiring to fetch keys on-demand rather than shipping unmasked keys in list payloads.

Overall the implementation is solid with good test coverage. One UX issue identified where API failures are silent.

PR Size: XL

  • Lines changed: 5,763 (5,713 additions, 50 deletions)
  • Files changed: 41

Split Suggestion: This PR could be split into 3 independent PRs:

  1. keys:reveal endpoint + UI rewiring (security improvement)
  2. Model redirect fallback regression tests (test-only)
  3. billNonSuccessfulRequests toggle feature (billing feature)

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 0 0 0
Security 0 0 0 0
Error Handling 0 1 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
Simplification 0 0 0 0

High Priority Issues (Should Fix)

[ERROR-SILENT] Silent failure on key reveal in key-list.tsx

  • File: src/app/[locale]/dashboard/_components/user/key-list.tsx
  • Issue: The fetchUnmaskedKey function returns null on API errors without any user feedback. Users clicking copy/reveal will see the button appear to do nothing on failure.
  • Fix: Add toast notification on error, following the pattern in key-row-item.tsx (which correctly handles this case with toast.error()).

Positive Notes

  1. Security: Unmasked keys are no longer shipped in user-list payloads - good security improvement
  2. Fail-closed design: The billNonSuccessfulRequests setting defaults to off and fails closed on settings read errors
  3. Test coverage: 15 new unit tests covering all new code paths (4 for keys-reveal, 7 for bill-non-success, 4 for model-redirect)
  4. i18n compliance: All user-facing strings use i18n across 5 languages
  5. Audit logging: Key reveal operations are properly audited with redaction
  6. Cache headers: The reveal endpoint correctly returns Cache-Control: no-store

Review Coverage

  • Logic and correctness - Clean
  • Security (OWASP Top 10) - Clean, no issues found
  • Error handling - One UX issue found (silent failure)
  • Type safety - Clean
  • Documentation accuracy - Clean
  • Test coverage - Adequate (15 new tests)
  • Code clarity - Good

Automated review by Claude AI

Resolves feedback from CodeRabbit, Greptile, gemini-code-assist, and chatgpt-codex
on PR #1149:

1. **Allow key owner to reveal their own key (HIGH)** — `canExposeFullKey` already
   marks an owner's keys as `canReveal: true` in list payloads, but the new
   `getUnmaskedKey` action and v1 router gated everything behind admin, causing
   a 403 regression for self-service users.
   - `getUnmaskedKey`: replace admin-only check with admin-OR-owner.
   - v1 `:reveal` route: relax `requireAuth("admin")` -> `requireAuth("read")`,
     update OpenAPI `x-required-access` to `admin | owner` and the description
     to reflect that ownership is checked at the action layer.
   - Test: replace "rejects non-admin" with two cases — owner allowed, non-owner
     rejected.

2. **Toast feedback on reveal/copy failure in key-list.tsx (HIGH)** —
   `fetchUnmaskedKey` was silently returning null. Mirror the key-row-item
   pattern: import `toast` from sonner and surface failures via
   `tCommon("copyFailed")`.

3. **Drop stale revealedKey cache when row identity changes (MAJOR)** — two
   call sites:
   - `key-row-item.tsx`: `useEffect` resets `revealedKey` and closes the dialog
     whenever `keyData.id` or `keyData.maskedKey` changes (parent may reuse the
     same component instance for a different row).
   - `key-list.tsx`: prune `revealedKeys` and `visibleKeyIds` whenever the
     incoming `keys` set changes so removed/replaced ids cannot leak their
     cached plaintext onto a new row.

4. **Missing assertion in model-redirect-fallback test 3 (MINOR)** — the
   "no rules" reset path must rewrite both `request.model` and
   `request.message.model`. Add the missing `request.message.model` check to
   guard against half-reset regressions.

Tests: 16/16 pass (3 files), typecheck clean, lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/app/[locale]/dashboard/_components/user/key-row-item.tsx (1)

213-243: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

仍有旧请求回写竞态,可能把上一行明文写入当前行

当前只在 key 切换时清空缓存,但没有防止“旧请求晚到并回写”。在组件复用场景下,这会造成跨行明文泄露。

建议修复(增加行身份守卫)
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";

   const [revealedKey, setRevealedKey] = useState<string | null>(null);
   const [isRevealing, setIsRevealing] = useState(false);
+  const activeKeyIdRef = useRef<number>(keyData.id);

   useEffect(() => {
+    activeKeyIdRef.current = keyData.id;
     setRevealedKey(null);
     setFullKeyDialogOpen(false);
+    setIsRevealing(false);
   }, [keyData.id, keyData.maskedKey]);

   const fetchUnmaskedKey = async (): Promise<string | null> => {
     if (revealedKey) return revealedKey;
+    const requestedKeyId = keyData.id;
     setIsRevealing(true);
     try {
-      const res = await getUnmaskedKey(keyData.id);
+      const res = await getUnmaskedKey(requestedKeyId);
+      if (activeKeyIdRef.current !== requestedKeyId) return null;
       if (!res.ok) {
         toast.error(res.error || translations.actions.copyFailed);
         return null;
       }
       if (!res.data?.key) {
         toast.error(translations.actions.copyFailed);
         return null;
       }
       setRevealedKey(res.data.key);
       return res.data.key;
     } catch (error) {
       console.error("[KeyRowItem] reveal failed", error);
       toast.error(translations.actions.copyFailed);
       return null;
     } finally {
-      setIsRevealing(false);
+      if (activeKeyIdRef.current === requestedKeyId) {
+        setIsRevealing(false);
+      }
     }
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/dashboard/_components/user/key-row-item.tsx around lines
213 - 243, The fetchUnmaskedKey async can suffer a stale-response race where a
slow previous getUnmaskedKey response overwrites the current row's revealedKey;
fix by adding an identity guard around the async call: capture a local token
(e.g. current keyData.id or an incrementing requestId) at the start of
fetchUnmaskedKey and, before calling setRevealedKey or changing state based on
the response, verify the token still matches the current row (keyData.id or
latest requestId). Alternatively implement an AbortController per fetch and
abort any prior fetch when keyData.id/maskedKey changes (in the useEffect
cleanup), and only update state if the fetch was not aborted. Ensure
setIsRevealing and setFullKeyDialogOpen updates respect the same guard so stale
responses cannot mutate the new row.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/actions/keys.ts`:
- Around line 879-891: The catch block is currently returning the raw
error.message to callers (via variable message) which leaks implementation
details; instead keep detailed error information in
logger.error(logger.error("获取密钥失败:", error)) and change the returned error to a
stable, non-sensitive token (e.g. "KEY_REVEAL_FAILED" or a generic
"INTERNAL_ERROR") so that the API response does not expose internal
messages—update the return to use that constant and remove the conditional that
sets message from error.message; leave emitActionAudit(...) as-is using the
existing error token.

In `@src/app/api/v1/resources/keys/router.ts`:
- Around line 236-239: KeyRevealResponseSchema 的字段描述仍写着“仅 admin”,与该路由在
router.ts(路由已标注为 admin | owner)权限不一致,会误导生成的 OpenAPI/SDK;请在
KeyRevealResponseSchema 中更新相关字段描述(或总体 schema 描述)去掉“仅 admin”字样并改为准确的权限语义(例如
“admin | owner” 或移除角色限定),确保与路由描述一致,同时检查任何使用 KeyRevealResponseSchema 的地方(如 200
响应定义)以同步更新文案。

---

Duplicate comments:
In `@src/app/`[locale]/dashboard/_components/user/key-row-item.tsx:
- Around line 213-243: The fetchUnmaskedKey async can suffer a stale-response
race where a slow previous getUnmaskedKey response overwrites the current row's
revealedKey; fix by adding an identity guard around the async call: capture a
local token (e.g. current keyData.id or an incrementing requestId) at the start
of fetchUnmaskedKey and, before calling setRevealedKey or changing state based
on the response, verify the token still matches the current row (keyData.id or
latest requestId). Alternatively implement an AbortController per fetch and
abort any prior fetch when keyData.id/maskedKey changes (in the useEffect
cleanup), and only update state if the fetch was not aborted. Ensure
setIsRevealing and setFullKeyDialogOpen updates respect the same guard so stale
responses cannot mutate the new row.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f3d929bd-815a-4bc3-a0af-d036c7bbf993

📥 Commits

Reviewing files that changed from the base of the PR and between 4ac4541 and 12bc4d2.

📒 Files selected for processing (6)
  • src/actions/keys.ts
  • src/app/[locale]/dashboard/_components/user/key-list.tsx
  • src/app/[locale]/dashboard/_components/user/key-row-item.tsx
  • src/app/api/v1/resources/keys/router.ts
  • tests/unit/actions/keys-reveal.test.ts
  • tests/unit/proxy/model-redirect-fallback.test.ts
✅ Files skipped from review due to trivial changes (1)
  • tests/unit/proxy/model-redirect-fallback.test.ts

Comment thread src/actions/keys.ts
Comment on lines +879 to +891
} catch (error) {
logger.error("获取密钥失败:", error);
const message = error instanceof Error ? error.message : "获取密钥失败";
emitActionAudit({
category: "key",
action: "key.key_reveal",
targetType: "key",
targetId: String(keyId),
success: false,
errorMessage: "KEY_REVEAL_FAILED",
});
return { ok: false, error: message };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

避免把底层异常原文返回给调用方

Line 881-882/890 直接回传 error.message,会把内部实现细节暴露到 API 响应。建议仅返回稳定错误文案,详细错误保留在日志中。

建议修复
   } catch (error) {
     logger.error("获取密钥失败:", error);
-    const message = error instanceof Error ? error.message : "获取密钥失败";
+    const message = "获取密钥失败";
     emitActionAudit({
       category: "key",
       action: "key.key_reveal",
       targetType: "key",
       targetId: String(keyId),
       success: false,
       errorMessage: "KEY_REVEAL_FAILED",
     });
     return { ok: false, error: message };
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
logger.error("获取密钥失败:", error);
const message = error instanceof Error ? error.message : "获取密钥失败";
emitActionAudit({
category: "key",
action: "key.key_reveal",
targetType: "key",
targetId: String(keyId),
success: false,
errorMessage: "KEY_REVEAL_FAILED",
});
return { ok: false, error: message };
}
} catch (error) {
logger.error("获取密钥失败:", error);
const message = "获取密钥失败";
emitActionAudit({
category: "key",
action: "key.key_reveal",
targetType: "key",
targetId: String(keyId),
success: false,
errorMessage: "KEY_REVEAL_FAILED",
});
return { ok: false, error: message };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/keys.ts` around lines 879 - 891, The catch block is currently
returning the raw error.message to callers (via variable message) which leaks
implementation details; instead keep detailed error information in
logger.error(logger.error("获取密钥失败:", error)) and change the returned error to a
stable, non-sensitive token (e.g. "KEY_REVEAL_FAILED" or a generic
"INTERNAL_ERROR") so that the API response does not expose internal
messages—update the return to use that constant and remove the conditional that
sets message from error.message; leave emitActionAudit(...) as-is using the
existing error token.

Comment on lines +236 to +239
200: {
description: "Unmasked key.",
content: { "application/json": { schema: KeyRevealResponseSchema } },
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

同步修正响应 schema 描述,避免 OpenAPI 权限语义冲突

当前路由描述(Line 231)已明确是 admin | owner,但 KeyRevealResponseSchema 的字段描述仍是“仅 admin”,会让生成文档/SDK 产生误导。

建议修复
--- a/src/lib/api/v1/schemas/keys.ts
+++ b/src/lib/api/v1/schemas/keys.ts
 export const KeyRevealResponseSchema = z.object({
-  key: z.string().describe("Unmasked key value. Returned only to admin callers."),
+  key: z.string().describe("Unmasked key value. Returned only to admin callers or the key owner."),
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/v1/resources/keys/router.ts` around lines 236 - 239,
KeyRevealResponseSchema 的字段描述仍写着“仅 admin”,与该路由在 router.ts(路由已标注为 admin |
owner)权限不一致,会误导生成的 OpenAPI/SDK;请在 KeyRevealResponseSchema 中更新相关字段描述(或总体 schema
描述)去掉“仅 admin”字样并改为准确的权限语义(例如 “admin | owner” 或移除角色限定),确保与路由描述一致,同时检查任何使用
KeyRevealResponseSchema 的地方(如 200 响应定义)以同步更新文案。

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12bc4d2f52

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/actions/keys.ts
Comment on lines +849 to +853
const isAdmin = session.user.role === "admin";
const isOwner = session.user.id === key.userId;
if (!isAdmin && !isOwner) {
return { ok: false, error: "无权限执行此操作" };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce Web-UI key permission in key reveal auth

getUnmaskedKey now authorizes any owner (session.user.id === key.userId) without checking session.key.canLoginWebUi, so a read-only credential that cannot log into the dashboard can still call GET /api/v1/keys/{id}:reveal and retrieve plaintext keys for that user. This breaks the existing exposure contract used in canExposeFullKey (which requires session.key.canLoginWebUi) and turns low-privilege API keys into a way to exfiltrate sibling keys.

Useful? React with 👍 / 👎.

… enum

CI on commit 12bc4d2 failed two checks:
- openapi-contract: 'admin | owner' is not in the allowed enum
  (public/read/admin) for x-required-access.
- openapi-types-drift: generated types out of date because the route
  description / annotation changed.

Fix: keep the auth tier as 'read' (already what `requireAuth("read")`
applies on the route) and document the admin-OR-owner contract in the
OpenAPI 'description' field instead. Regenerate openapi-types.gen.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 2, 2026

Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

@ding113 ding113 merged commit b588663 into dev May 2, 2026
10 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Claude Code Hub Roadmap May 2, 2026
...problemResponses,
},
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [LOGIC-BUG] canReveal/:reveal permission mismatch breaks key reveal/copy for non-admin users

Why this is a problem:

  • src/actions/users.ts:420 sets canReveal: canUserManageKey, which is used by the dashboard to decide whether to render reveal/copy UI.
  • src/app/[locale]/dashboard/_components/user/key-list.tsx:217 renders the copy/show controls when record.canReveal is true.
  • But the new management endpoint is guarded as admin-only: src/app/api/v1/resources/keys/router.ts:245 is keysRouter.get("/keys/:keyId{[0-9]+:reveal}", requireAuth("admin"), revealKey);.
  • The new unit test also codifies admin-only behavior: tests/unit/actions/keys-reveal.test.ts:74 (it("rejects non-admin callers" ...)).

Net effect: a non-admin user will receive canReveal=true for their own keys (UI shows buttons), but the GET /api/v1/keys/{id}:reveal call is denied (403), so reveal/copy is effectively broken.

Suggested fix (choose one and make all call sites consistent):

// Option A (minimal, matches current router + tests): only advertise reveal/copy for admins
// src/actions/users.ts
const canUserManageKey = canExposeFullKey(session, user, isAdmin);
const canReveal = isAdmin && canUserManageKey;

return {
  ...,
  canReveal,
  canCopy: canReveal,
  ...,
};

If non-admin users are intended to reveal their own keys, then requireAuth("admin") on src/app/api/v1/resources/keys/router.ts:245 needs to be relaxed and getUnmaskedKey must enforce session.user.id === key.userId || role === "admin".

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR bundles multiple independent changes (keys reveal flow, billing toggle, schema/migration artifacts, and proxy/test updates) into a single XL diff, which makes regression detection and review verification harder. There is a permission/contract mismatch in the new key reveal path that can surface broken reveal/copy UI for non-admin users.

PR Size: XL

  • Lines changed: 5763
  • Files changed: 41
  • Split suggestion (recommended): split into (1) keys:{id}:reveal API + dashboard wiring, (2) billNonSuccessfulRequests schema/migration + proxy cost-gate + settings UI, (3) model redirect fallback tests.

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 1 0 0
Security 0 0 0 0
Error Handling 0 0 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
Simplification 0 0 0 0

Critical Issues (Must Fix)

  1. [High] [LOGIC-BUG] canReveal/:reveal permission mismatch breaks key reveal/copy for non-admin users
    • Evidence: src/actions/users.ts:420 (canReveal: canUserManageKey,) + UI gating src/app/[locale]/dashboard/_components/user/key-list.tsx:217 ({record.canReveal && ...}) vs admin-only endpoint src/app/api/v1/resources/keys/router.ts:245 (requireAuth("admin")) and admin-only action contract in tests/unit/actions/keys-reveal.test.ts:74.

High Priority Issues (Should Fix)

None.

Review Coverage

  • Logic and correctness
  • Security (OWASP Top 10)
  • Error handling
  • Type safety
  • Documentation accuracy
  • Test coverage
  • Code clarity

Automated review by Codex AI

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Identified current PR as #1149 (fix: keys:reveal + redirect fallback test + bill-on-failure toggle) and applied the size label size/XL (41 files, 5763 lines changed).
  • Posted 1 inline review comment calling out a permission/contract mismatch that can break key reveal/copy for non-admin users: src/app/api/v1/resources/keys/router.ts:245.
  • Submitted the required review summary via gh pr review --comment (includes XL split suggestions and the issue matrix).

@ding113 ding113 deleted the fix/key-reveal-redirect-fallback-bill-toggle branch May 13, 2026 06:22
@github-actions github-actions Bot mentioned this pull request May 13, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core area:UI bug Something isn't working size/XL Extra Large PR (> 1000 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant