Skip to content

[codex] Add scoped API key copy flow for Api Manager - #740

Merged
diegosouzapw merged 1 commit into
diegosouzapw:mainfrom
rdself:coder/api-key-reveal-scoped
Mar 29, 2026
Merged

diegosouzapw merged 1 commit into
diegosouzapw:mainfrom
rdself:coder/api-key-reveal-scoped

Conversation

@rdself

@rdself rdself commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add an ALLOW_API_KEY_REVEAL environment flag that is disabled by default
  • keep /api/keys masked for all dashboard consumers while exposing reveal capability only through an explicit /api/keys/[id]/reveal route
  • update Api Manager to replace the lock icon with a copy action when reveal is enabled, and document/test the new behavior

Why

Administrators sometimes need to copy an existing local API key again after creation, but the dashboard currently only shows the full value once. This change enables an intentional, opt-in copy flow without turning the shared key list endpoint into a general full-key response.

Impact

  • default behavior is unchanged: stored API keys remain masked in dashboard lists
  • setting ALLOW_API_KEY_REVEAL=true enables one-click copy from Api Manager
  • other dashboard screens that reuse /api/keys continue to receive masked key values

Validation

  • node --import tsx/esm --test tests/unit/api-key-reveal-route.test.mjs
  • npm run typecheck:core
  • git diff --check
  • npm run test:unit

Copilot AI review requested due to automatic review settings March 29, 2026 06:13
import { isApiKeyRevealEnabled } from "@/lib/apiKeyExposure";

// GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions
export async function GET(_request, { params }) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: Missing authentication check — The route exposes full API keys without verifying the request is from an authenticated user.

Other management routes (e.g., /api/db-backups/export/route.ts) use this pattern:

if (await isAuthRequired()) {
  if (!(await isAuthenticated(request))) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
}

Without this check, anyone who can access the dashboard (or if REQUIRE_API_KEY=false, anyone on the network) can retrieve full API keys by calling /api/keys/[id]/reveal.

if (!keyId) return;

try {
const res = await fetch(`/api/keys/${encodeURIComponent(keyId)}/reveal`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: No user feedback on failure — When the reveal request fails (e.g., network error, server error), the user is not informed. The error is only logged to console.

Consider adding error state to inform users why the copy operation failed.

@kilo-code-bot

kilo-code-bot Bot commented Mar 29, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 1 Critical Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

CRITICAL

File Line Issue
src/app/api/keys/[id]/reveal/route.ts 6 Missing authentication check — exposes full API keys to unauthenticated callers

WARNING

File Line Issue
src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx 297 No user feedback on reveal failure — only logs to console
Files Reviewed (6 files)
  • .env.example - env var documentation
  • docs/USER_GUIDE.md - documentation update
  • src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx - UI changes
  • src/app/api/keys/[id]/reveal/route.ts - NEW: reveal endpoint (CRITICAL issue)
  • src/app/api/keys/route.ts - modified list endpoint
  • src/lib/apiKeyExposure.ts - NEW: helper module
  • tests/unit/api-key-reveal-route.test.mjs - NEW: unit tests

@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 feature to reveal and copy full API keys, managed by the ALLOW_API_KEY_REVEAL environment variable. It includes a new API endpoint, UI updates for the reveal action, and utility functions for key masking. Review feedback suggests enhancing user-facing error handling in the dashboard and simplifying the handling of route parameters by removing unnecessary await calls in the API route and unit tests.

Comment on lines +293 to +310
const handleCopyExistingKey = async (keyId: string) => {
if (!keyId) return;

try {
const res = await fetch(`/api/keys/${encodeURIComponent(keyId)}/reveal`);
if (!res.ok) {
console.log("Error revealing key:", await res.text());
return;
}

const data = await res.json();
if (typeof data?.key === "string") {
await copy(data.key, `existing_key_${keyId}`);
}
} catch (error) {
console.log("Error copying existing key:", 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.

medium

The current implementation of handleCopyExistingKey logs errors to the console, which is not visible to the user. For a better user experience, it's recommended to use the existing setError state to display a user-facing error message in the UI, similar to how other actions in this component are handled. This provides clear feedback to the user if the copy action fails. You may need to add new translation keys like failedToRevealKey and failedToCopyKey.

  const handleCopyExistingKey = async (keyId: string) => {
    if (!keyId) return;

    clearError();
    try {
      const res = await fetch(`/api/keys/${encodeURIComponent(keyId)}/reveal`);
      if (!res.ok) {
        const data = await res.json().catch(() => ({ error: t("failedToRevealKey") }));
        setError(data.error || t("failedToRevealKey"));
        return;
      }

      const data = await res.json();
      if (typeof data?.key === "string") {
        await copy(data.key, `existing_key_${keyId}`);
      }
    } catch (error) {
      console.error("Error copying existing key:", error);
      setError(t("failedToCopyKey"));
    }
  };

return NextResponse.json({ error: "API key reveal is disabled" }, { status: 403 });
}

const { id } = await params;

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

In Next.js App Router route handlers, the params object is not a promise. The await here is unnecessary and likely a leftover from a testing setup. It should be removed for code clarity and correctness in a production environment.

Suggested change
const { id } = await params;
const { id } = params;

Comment on lines +58 to +60
const response = await revealRoute.GET(request, {
params: Promise.resolve({ id: created.id }),
});

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

The params object passed to the route handler should be a plain object, not a promise. The route handler in Next.js receives { params } directly. Wrapping it in Promise.resolve in the test forces an unnecessary await in the production code. This should be corrected here and in the other test case.

  const response = await revealRoute.GET(request, {
    params: { id: created.id },
  });

Comment on lines +72 to +74
const response = await revealRoute.GET(request, {
params: Promise.resolve({ id: created.id }),
});

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

Similar to the other test case, the params object should be a plain object, not a promise, to accurately reflect the Next.js route handler signature.

  const response = await revealRoute.GET(request, {
    params: { id: created.id },
  });

Copilot AI 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.

Pull request overview

Adds an opt-in “reveal + copy existing API key” flow for the dashboard Api Manager while keeping the general /api/keys listing masked by default.

Changes:

  • Introduces ALLOW_API_KEY_REVEAL flag plus shared helpers for reveal enablement and key masking.
  • Extends GET /api/keys to keep keys masked while returning allowKeyReveal to drive UI behavior.
  • Adds GET /api/keys/[id]/reveal and updates Api Manager UI + docs/tests to support on-demand copy.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/unit/api-key-reveal-route.test.mjs Adds unit coverage for masked list behavior and the new reveal endpoint gating.
src/lib/apiKeyExposure.ts Centralizes reveal-flag parsing and key masking logic.
src/app/api/keys/route.ts Uses shared masking helper and returns allowKeyReveal flag.
src/app/api/keys/[id]/reveal/route.ts New endpoint to return full key by id when reveal is enabled.
src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx Adds UI state/behavior to show copy action and call reveal endpoint when enabled.
docs/USER_GUIDE.md Documents ALLOW_API_KEY_REVEAL.
.env.example Adds ALLOW_API_KEY_REVEAL example entry.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/apiKeyExposure.ts

export function maskStoredApiKey(key: unknown): string | null {
if (typeof key !== "string") return null;
return key.slice(0, 8) + "****" + key.slice(-4);

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

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

maskStoredApiKey can fully disclose old/short API keys because the prefix (8) and suffix (4) slices overlap when key.length <= 12 (e.g., legacy format sk-{random8}), making the “masked” string reconstructable. Consider guarding on length and masking more aggressively for short keys (or choosing prefix/suffix lengths that never overlap).

Suggested change
return key.slice(0, 8) + "****" + key.slice(-4);
const str = key as string;
const len = str.length;
// For empty or very short keys, mask aggressively to avoid leaking the full value.
if (len === 0) {
return null;
}
if (len <= 4) {
return "****";
}
// For short keys, ensure prefix and suffix do not cover the entire string.
if (len <= 12) {
// Reveal only first and last character, mask everything in between.
return str[0] + "****" + str.slice(-1);
}
// For longer keys, keep existing behavior: show first 8 and last 4 characters.
return str.slice(0, 8) + "****" + str.slice(-4);

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +13
// GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions
export async function GET(_request, { params }) {
try {
if (!isApiKeyRevealEnabled()) {
return NextResponse.json({ error: "API key reveal is disabled" }, { status: 403 });
}

const { id } = await params;
const key = await getApiKeyById(id);

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

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

The reveal handler doesn’t enforce management-route authentication. In production this may be covered by proxy middleware, but unit tests and any direct invocation will bypass that guard, and this endpoint returns a full secret. Consider calling requireManagementAuth(request) (or isAuthRequired + isAuthenticated) early and returning 401 when auth is required but missing.

Copilot uses AI. Check for mistakes.
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}

return NextResponse.json({ key: key.key });

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

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

This endpoint returns a sensitive secret but doesn’t set any cache-prevention headers. Add Cache-Control: no-store (and optionally Pragma: no-cache) on the 200 response to reduce the risk of intermediary/browser caching of the revealed API key.

Suggested change
return NextResponse.json({ key: key.key });
return NextResponse.json(
{ key: key.key },
{
headers: {
"Cache-Control": "no-store",
"Pragma": "no-cache",
},
},
);

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +22

// GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions
export async function GET(_request, { params }) {
try {
if (!isApiKeyRevealEnabled()) {
return NextResponse.json({ error: "API key reveal is disabled" }, { status: 403 });
}

const { id } = await params;
const key = await getApiKeyById(id);

if (!key || typeof key.key !== "string") {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}

return NextResponse.json({ key: key.key });
} catch (error) {
console.log("Error revealing key:", error);
return NextResponse.json({ error: "Failed to reveal key" }, { status: 500 });

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

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

This new management API route uses ad-hoc NextResponse.json({ error: "..." }) payloads. The repo has canonical helpers in src/shared/utils/apiResponse.ts intended for management routes (structured { error: { code, message, ... } }). Using those helpers here would keep client error handling consistent.

Suggested change
// GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions
export async function GET(_request, { params }) {
try {
if (!isApiKeyRevealEnabled()) {
return NextResponse.json({ error: "API key reveal is disabled" }, { status: 403 });
}
const { id } = await params;
const key = await getApiKeyById(id);
if (!key || typeof key.key !== "string") {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
return NextResponse.json({ key: key.key });
} catch (error) {
console.log("Error revealing key:", error);
return NextResponse.json({ error: "Failed to reveal key" }, { status: 500 });
import { errorResponse, successResponse } from "@/shared/utils/apiResponse";
// GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions
export async function GET(_request, { params }) {
try {
if (!isApiKeyRevealEnabled()) {
return errorResponse(
403,
"API_KEY_REVEAL_DISABLED",
"API key reveal is disabled",
);
}
const { id } = await params;
const key = await getApiKeyById(id);
if (!key || typeof key.key !== "string") {
return errorResponse(404, "API_KEY_NOT_FOUND", "Key not found");
}
return successResponse({ key: key.key });
} catch (error) {
console.log("Error revealing key:", error);
return errorResponse(500, "API_KEY_REVEAL_FAILED", "Failed to reveal key");

Copilot uses AI. Check for mistakes.
@diegosouzapw
diegosouzapw merged commit d352b6b into diegosouzapw:main Mar 29, 2026
1 check passed
@diegosouzapw

Copy link
Copy Markdown
Owner

Thanks @rdself for this great contribution! 🎉 The feature is now merged and will be part of the next release. We appreciate your effort!

@rdself
rdself deleted the coder/api-key-reveal-scoped branch April 16, 2026 01:14
@diegosouzapw diegosouzapw mentioned this pull request Apr 22, 2026
diegosouzapw added a commit that referenced this pull request Jul 18, 2026
…) (#7628)

Validated in local merge-train @ 8f27177d1 (full parity suite green: typecheck+file-size+complexity+cognitive+changelog+unit shards 1&2+vitest)
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
…uzapw#740/diegosouzapw#741) (diegosouzapw#7628)

Validated in local merge-train @ 8f27177d1 (full parity suite green: typecheck+file-size+complexity+cognitive+changelog+unit shards 1&2+vitest)
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…uzapw#740/diegosouzapw#741) (diegosouzapw#7628)

Validated in local merge-train @ 8f27177d1 (full parity suite green: typecheck+file-size+complexity+cognitive+changelog+unit shards 1&2+vitest)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants