From 24e81878e1c9165b26c2ccc01ce9fdd404e68ace Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Wed, 11 Nov 2026 13:51:23 +0100 Subject: [PATCH 01/15] feat(cli): accept VERYFRONT_API_KEY as the one credential name Studio, the gateway snippets and the docs tell users to export VERYFRONT_API_KEY, but the CLI and SDK only read VERYFRONT_API_TOKEN, so 'veryfront whoami' said 'Not logged in' with the key the user just set. The CLI and SDK now read VERYFRONT_API_KEY too: - After each env load, a key is copied into the VERYFRONT_API_TOKEN slot once, only when that slot is unset or blank, and with the key's provenance, so every repository trust rule judges a .env key exactly like a .env token (including $NAME expansion taint). - Processes that never load env files (SDK scripts) read the key through getHostEnv. Once the loader has run, getHostEnv never falls back, so a token the CLI removed on purpose cannot come back through the key. - VERYFRONT_API_TOKEN keeps working and wins when both are set. Help, errors, docs and SDK examples now name VERYFRONT_API_KEY; messages about an active credential name the variable the user actually set. The MCP deploy tool classifies the missing-credential error by an exported constant instead of copying its wording. Refs veryfront/veryfront-issue-inbox#4088 Refs veryfront/veryfront-issue-inbox#4220 --- cli/auth/exit-code.integration.test.ts | 2 +- cli/auth/login.test.ts | 106 ++++++++++++++- cli/auth/login.ts | 31 +++-- cli/commands/deploy/command-help.ts | 2 +- cli/commands/env/command-help.ts | 2 +- cli/commands/login/command-help.ts | 2 +- cli/commands/logout/command-help.ts | 2 +- cli/commands/merge/command-help.ts | 2 +- cli/commands/pull/command-help.ts | 2 +- cli/commands/pull/command.ts | 2 +- .../veryfront/references/INTEGRATIONS.md | 2 +- cli/mcp/standalone.ts | 2 +- cli/mcp/tools/deploy-tool.test.ts | 25 ++++ cli/mcp/tools/deploy-tool.ts | 5 +- cli/shared/config.test.ts | 125 +++++++++++++++++- cli/shared/config.ts | 19 ++- cli/shared/deployment/deploy-project.ts | 3 +- cli/skills/core-skills.generated.ts | 2 +- docs/api-reference/veryfront/conversations.md | 8 +- docs/api-reference/veryfront/runs.md | 2 +- docs/getting-started/deploy-project.md | 4 +- docs/guides/agents.md | 4 +- docs/guides/build-a-rag-app.md | 4 +- docs/guides/cli-knowledge-ingestion.md | 4 +- docs/guides/cloud-environment-access.md | 2 +- docs/guides/coding-agents.md | 2 +- docs/guides/configuration.md | 8 +- docs/guides/connect-runtime.md | 12 +- docs/guides/deploy-from-ci.md | 4 +- docs/guides/deploying.md | 2 +- docs/guides/errors.md | 2 +- docs/guides/integrations.md | 6 +- docs/guides/integrations/credentials.md | 4 +- docs/guides/providers.md | 11 +- docs/guides/runs.md | 8 +- docs/guides/sandbox.md | 4 +- docs/guides/tasks.md | 4 +- docs/guides/tools.md | 2 +- scripts/docs/validate-public-docs.ts | 4 +- .../runtime/mcp-server-tool-sources.test.ts | 12 +- src/agent/runtime/mcp-server-tool-sources.ts | 7 +- src/conversations/index.ts | 8 +- src/errors/error-registry/agent.ts | 2 +- src/platform/compat/process/env.test.ts | 78 +++++++++++ src/platform/compat/process/env.ts | 38 +++++- src/runs/index.ts | 2 +- src/runs/target/README.md | 2 +- src/runs/target/index.ts | 2 +- src/sandbox/config.ts | 2 +- src/sandbox/sandbox.test-helpers.ts | 1 + src/sandbox/sandbox.test.ts | 38 ++++++ src/utils/env-loader.test.ts | 125 +++++++++++++++++- src/utils/env-loader.ts | 61 +++++++++ src/workflow/blob/veryfront-cloud-storage.ts | 2 +- tests/docs/guide-code-examples.test.ts | 2 +- tests/docs/guide-content.test.ts | 4 + tests/docs/guide-contracts.test.ts | 4 +- .../cli/agent-ux-contract.integration.test.ts | 2 +- 58 files changed, 715 insertions(+), 114 deletions(-) diff --git a/cli/auth/exit-code.integration.test.ts b/cli/auth/exit-code.integration.test.ts index 79a606dbaa..c7306da68f 100644 --- a/cli/auth/exit-code.integration.test.ts +++ b/cli/auth/exit-code.integration.test.ts @@ -68,7 +68,7 @@ describe("cli/auth exit codes", () => { code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Run 'veryfront login' to authenticate.", + message: "Not logged in. Run 'veryfront login', or set VERYFRONT_API_KEY to an API key.", context: { authenticated: false }, }, }); diff --git a/cli/auth/login.test.ts b/cli/auth/login.test.ts index 02ff3aa210..061b864a8d 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -836,7 +836,7 @@ describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () => code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.", + message: "Not logged in. Set VERYFRONT_API_KEY or run in interactive mode.", }, }); assertEquals(output.join("\n").includes("Enter your API token"), false); @@ -1202,7 +1202,7 @@ describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () => code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.", + message: "Not logged in. Set VERYFRONT_API_KEY or run in interactive mode.", }); assertEquals(requestedAuth, ["Bearer env-invalid-token"]); assertEquals(output.join("\n").includes("stored@example.com"), false); @@ -1487,7 +1487,7 @@ describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () => code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.", + message: "Not logged in. Set VERYFRONT_API_KEY or run in interactive mode.", }); assertEquals(requestedAuth, ["Bearer config-invalid-token"]); assertEquals(output.join("\n").includes("stored@example.com"), false); @@ -3148,7 +3148,8 @@ describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () => code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Run 'veryfront login' to authenticate.", + message: + "Not logged in. Run 'veryfront login', or set VERYFRONT_API_KEY to an API key.", context: { authenticated: false }, }, }); @@ -3316,7 +3317,8 @@ describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () => code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Run 'veryfront login' to authenticate.", + message: + "Not logged in. Run 'veryfront login', or set VERYFRONT_API_KEY to an API key.", context: { authenticated: false }, }, }); @@ -3452,6 +3454,100 @@ describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () => ); }); + it("names VERYFRONT_API_KEY when the key supplied the credential", async () => { + const originalLog = console.log; + const output: string[] = []; + const { __resetEnvLoaderForTests, loadEnv } = await import("veryfront/utils/env-loader"); + const envDir = await makeTempDir({ prefix: "whoami-api-key-" }); + const savedKey = getEnv("VERYFRONT_API_KEY"); + + try { + deleteEnv("VERYFRONT_API_TOKEN"); + deleteEnv("VERYFRONT_API_KEY"); + __resetEnvLoaderForTests(); + await Deno.writeTextFile(`${envDir}/.env`, "VERYFRONT_API_KEY=vf_test_file_key\n"); + await loadEnv({ cwd: envDir }); + console.log = (message?: unknown) => output.push(String(message)); + + await withMockFetch( + () => + Promise.resolve( + new Response(JSON.stringify({ data: [], page_info: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + async () => { + const { whoami } = await import("./login.ts"); + await whoami({ + ...testEnv, + apiBaseUrl: "https://auth.example.test", + apiUrl: undefined, + apiToken: getEnv("VERYFRONT_API_TOKEN"), + }); + }, + ); + + const printed = output.join("\n"); + assertStringIncludes(printed, "(via VERYFRONT_API_KEY from .env)"); + assertEquals(printed.includes("VERYFRONT_API_TOKEN"), false, "the user set only the key"); + assertEquals(printed.includes("vf_test_file_key"), false, "the key is never printed"); + } finally { + console.log = originalLog; + __resetEnvLoaderForTests(); + deleteEnv("VERYFRONT_API_TOKEN"); + if (savedKey === undefined) deleteEnv("VERYFRONT_API_KEY"); + else setEnv("VERYFRONT_API_KEY", savedKey); + await remove(envDir, { recursive: true }); + } + }); + + it("names VERYFRONT_API_KEY for an exported key", async () => { + const originalLog = console.log; + const output: string[] = []; + const { __resetEnvLoaderForTests, loadEnv } = await import("veryfront/utils/env-loader"); + const envDir = await makeTempDir({ prefix: "whoami-api-key-shell-" }); + const savedKey = getEnv("VERYFRONT_API_KEY"); + + try { + deleteEnv("VERYFRONT_API_TOKEN"); + setEnv("VERYFRONT_API_KEY", "vf_test_shell_key"); + __resetEnvLoaderForTests(); + await loadEnv({ cwd: envDir }); + console.log = (message?: unknown) => output.push(String(message)); + + await withMockFetch( + () => + Promise.resolve( + new Response(JSON.stringify({ data: [], page_info: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + async () => { + const { whoami } = await import("./login.ts"); + await whoami({ + ...testEnv, + apiBaseUrl: "https://auth.example.test", + apiUrl: undefined, + apiToken: getEnv("VERYFRONT_API_TOKEN"), + }); + }, + ); + + const printed = output.join("\n"); + assertStringIncludes(printed, "(via VERYFRONT_API_KEY)"); + assertEquals(printed.includes("vf_test_shell_key"), false, "the key is never printed"); + } finally { + console.log = originalLog; + __resetEnvLoaderForTests(); + deleteEnv("VERYFRONT_API_TOKEN"); + if (savedKey === undefined) deleteEnv("VERYFRONT_API_KEY"); + else setEnv("VERYFRONT_API_KEY", savedKey); + await remove(envDir, { recursive: true }); + } + }); + it("does not attribute an injected API token to a different loaded .env token", async () => { const originalFetch = globalThis.fetch; const originalLog = console.log; diff --git a/cli/auth/login.ts b/cli/auth/login.ts index 6ced28ea4d..ddc2376eb0 100644 --- a/cli/auth/login.ts +++ b/cli/auth/login.ts @@ -25,7 +25,11 @@ import { outputJson, } from "../shared/json-output.ts"; import { isInteractive } from "../shared/interactive.ts"; -import { getEnvSource } from "veryfront/utils/env-loader"; +import { + getEnvSource, + isVeryfrontApiTokenFromKey, + veryfrontApiCredentialEnvName, +} from "veryfront/utils/env-loader"; import { basename, isAbsolute, relative } from "veryfront/platform/path"; import { cwd, getEnv } from "veryfront/platform"; @@ -68,12 +72,15 @@ export function formatEnvSourcePathForDisplay(file: string, currentCwd = cwd()): } function describeApiTokenSource(token: string): string { + // The name the user actually set: `VERYFRONT_API_KEY` fills the token slot + // when only the key is set, and the user never typed `VERYFRONT_API_TOKEN`. + const name = isVeryfrontApiTokenFromKey() ? "VERYFRONT_API_KEY" : "VERYFRONT_API_TOKEN"; const origin = getEnvSource("VERYFRONT_API_TOKEN"); if (origin.source !== "env-file" || getEnv("VERYFRONT_API_TOKEN") !== token) { - return "(via VERYFRONT_API_TOKEN)"; + return `(via ${name})`; } - return `(via VERYFRONT_API_TOKEN from ${formatEnvSourcePathForDisplay(origin.file)})`; + return `(via ${name} from ${formatEnvSourcePathForDisplay(origin.file)})`; } type BrowserAuthMethod = "google" | "github" | "microsoft"; @@ -142,7 +149,7 @@ async function outputLoginAuthenticationRequiredJson(): Promise { code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.", + message: "Not logged in. Set VERYFRONT_API_KEY or run in interactive mode.", })); } @@ -539,7 +546,9 @@ function writeConfigFileSwitchingGuidance(): void { function writeEnvironmentConfigFileSwitchingGuidance(): void { console.log( " " + - dim("Remove or replace apiToken in veryfront.json after unsetting VERYFRONT_API_TOKEN."), + dim( + `Remove or replace apiToken in veryfront.json after unsetting ${veryfrontApiCredentialEnvName()}.`, + ), ); } @@ -549,7 +558,9 @@ function writeAuthoritativeCredentialRejectedMessage( ): void { console.log(); if (source === "environment") { - console.log(" " + error("✗") + " VERYFRONT_API_TOKEN was rejected by the Veryfront API."); + console.log( + " " + error("✗") + ` ${veryfrontApiCredentialEnvName()} was rejected by the Veryfront API.`, + ); console.log( " " + dim( @@ -898,7 +909,7 @@ export async function login( await assertApiUrlAcceptsNewCredential(env, projectDir); if (!isInteractive() && (method === undefined || method === "token")) { - cliLogger.error("Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode."); + cliLogger.error("Not logged in. Set VERYFRONT_API_KEY or run in interactive mode."); return null; } @@ -936,7 +947,7 @@ async function requestInteractiveAuthentication( validationOptions: CredentialValidationOptions, ): Promise { if (!isTTY() || !isInteractive()) { - cliLogger.error("Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode."); + cliLogger.error("Not logged in. Set VERYFRONT_API_KEY or run in interactive mode."); return null; } @@ -1091,7 +1102,7 @@ export async function whoami( code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Run 'veryfront login' to authenticate.", + message: "Not logged in. Run 'veryfront login', or set VERYFRONT_API_KEY to an API key.", context: { authenticated: false }, })); return null; @@ -1099,7 +1110,7 @@ export async function whoami( console.log(); console.log(" " + error("✗") + " Not logged in"); - console.log(" " + dim("Run 'veryfront login' to authenticate")); + console.log(" " + dim("Run 'veryfront login', or set VERYFRONT_API_KEY to an API key")); return null; } diff --git a/cli/commands/deploy/command-help.ts b/cli/commands/deploy/command-help.ts index f01664d738..fd10a5d328 100644 --- a/cli/commands/deploy/command-help.ts +++ b/cli/commands/deploy/command-help.ts @@ -44,7 +44,7 @@ export const deployHelp: CommandHelp = { "veryfront deploy --project my-app --environment production", ], notes: [ - "Requires VERYFRONT_API_TOKEN or an authenticated Veryfront login", + "Requires VERYFRONT_API_KEY or an authenticated Veryfront login", "Creates or links a project when veryfront.json is not present", "Promotes main when --branch is omitted", "Pushes main before the first deploy when no verified push exists", diff --git a/cli/commands/env/command-help.ts b/cli/commands/env/command-help.ts index 42d8a9a029..ce1a1a197a 100644 --- a/cli/commands/env/command-help.ts +++ b/cli/commands/env/command-help.ts @@ -29,7 +29,7 @@ export const envHelp: CommandHelp = { "veryfront env token --env production --json", ], notes: [ - "Requires VERYFRONT_API_TOKEN or an authenticated Veryfront login", + "Requires VERYFRONT_API_KEY or an authenticated Veryfront login", "The token is bound to one project and environment and expires after the API-provided lifetime", "Human output contains only the credential. Capture it with command substitution instead of pasting it into another command", ], diff --git a/cli/commands/login/command-help.ts b/cli/commands/login/command-help.ts index 9e49a132ba..8ac14cd356 100644 --- a/cli/commands/login/command-help.ts +++ b/cli/commands/login/command-help.ts @@ -32,7 +32,7 @@ export const loginHelp: CommandHelp = { ], notes: [ "Without options, a valid session returns immediately. Use an explicit method to sign in again. If veryfront.json contains apiToken, remove or replace it before using another method to switch accounts", - "If VERYFRONT_API_TOKEN is set in your shell, unset or replace it before using another method to switch accounts.", + "If VERYFRONT_API_KEY or VERYFRONT_API_TOKEN is set in your shell, unset or replace it before using another method to switch accounts.", "Explicit methods (--google, --github, --microsoft, --token) are not supported with --json. Combining them with --json returns a usage error.", "OAuth methods open browser for authentication", "Token is stored in ~/.config/veryfront/token", diff --git a/cli/commands/logout/command-help.ts b/cli/commands/logout/command-help.ts index a9c83e10a5..b2e4cc0a8d 100644 --- a/cli/commands/logout/command-help.ts +++ b/cli/commands/logout/command-help.ts @@ -9,6 +9,6 @@ export const logoutHelp: CommandHelp = { examples: ["veryfront logout"], notes: [ "Removes token from ~/.config/veryfront/token", - "Does not affect VERYFRONT_API_TOKEN environment variable", + "Does not affect the VERYFRONT_API_KEY or VERYFRONT_API_TOKEN environment variables", ], }; diff --git a/cli/commands/merge/command-help.ts b/cli/commands/merge/command-help.ts index b778eaefb3..e5a20629e2 100644 --- a/cli/commands/merge/command-help.ts +++ b/cli/commands/merge/command-help.ts @@ -25,7 +25,7 @@ export const mergeHelp: CommandHelp = { "veryfront merge feature-header --dry-run", ], notes: [ - "Requires VERYFRONT_API_TOKEN env var or veryfront.json config", + "Requires VERYFRONT_API_KEY or veryfront.json config", "A self-hosted apiUrl in veryfront.json or a project .env file needs a token from that same file, or VERYFRONT_API_URL set in your shell or CI environment to confirm the host", "Use --dry-run to preview which files would be merged", "--dry-run reports conflicting file paths; resolve those conflicts in Studio", diff --git a/cli/commands/pull/command-help.ts b/cli/commands/pull/command-help.ts index 3655696c82..8f36159b63 100644 --- a/cli/commands/pull/command-help.ts +++ b/cli/commands/pull/command-help.ts @@ -63,7 +63,7 @@ export const pullHelp: CommandHelp = { "veryfront pull --yes", ], notes: [ - "Requires VERYFRONT_API_TOKEN env var or veryfront.json config", + "Requires VERYFRONT_API_KEY or veryfront.json config", "A self-hosted apiUrl in veryfront.json or a project .env file needs a token from that same file, or VERYFRONT_API_URL set in your shell or CI environment to confirm the host", "Project slug is inferred from package.json name or directory", "With --projects, each project is pulled into a subdirectory named after the slug", diff --git a/cli/commands/pull/command.ts b/cli/commands/pull/command.ts index 51f5fff071..11413ba543 100644 --- a/cli/commands/pull/command.ts +++ b/cli/commands/pull/command.ts @@ -1082,7 +1082,7 @@ export function pullCommand(options: PullOptions = {}): Promise { const { apiUrl, apiToken } = await resolveApiCredentialForFallback(env, configFile); if (!apiToken) { throw new Error( - "VERYFRONT_API_TOKEN environment variable or apiToken in veryfront.json is required when using --projects", + "VERYFRONT_API_KEY or apiToken in veryfront.json is required when using --projects", ); } diff --git a/cli/mcp/skills/veryfront/references/INTEGRATIONS.md b/cli/mcp/skills/veryfront/references/INTEGRATIONS.md index 3b64b29a3e..4cecee600b 100644 --- a/cli/mcp/skills/veryfront/references/INTEGRATIONS.md +++ b/cli/mcp/skills/veryfront/references/INTEGRATIONS.md @@ -5,7 +5,7 @@ Full guide: https://veryfront.com/docs/code/guides/integrations ## Two separate logins -- **Platform login** (`veryfront login`, `VERYFRONT_API_TOKEN`) authenticates +- **Platform login** (`veryfront login`, `VERYFRONT_API_KEY`) authenticates you to Veryfront. It never connects a provider. - **Provider consent** (`veryfront integration connect "" --project ""`) authorizes one provider account for one project. It needs a person in a browser and an diff --git a/cli/mcp/standalone.ts b/cli/mcp/standalone.ts index da794f3550..323a7a7b82 100644 --- a/cli/mcp/standalone.ts +++ b/cli/mcp/standalone.ts @@ -656,7 +656,7 @@ export class StandaloneMCPServer { name: "vf_trigger_deploy", description: "Deploy a project to an environment via the Veryfront API. " + "Creates a release from the specified branch and deploys it to the target environment. " + - "Requires a valid API token (set VERYFRONT_API_TOKEN or run 'veryfront login'). " + + "Requires a valid API key (set VERYFRONT_API_KEY or run 'veryfront login'). " + "Do not use for local builds. Use vf_build instead. " + "Do not use for running tests before deploy. Use vf_run_tests instead.", inputSchema: { diff --git a/cli/mcp/tools/deploy-tool.test.ts b/cli/mcp/tools/deploy-tool.test.ts index 1968e9701c..e77cfd35cd 100644 --- a/cli/mcp/tools/deploy-tool.test.ts +++ b/cli/mcp/tools/deploy-tool.test.ts @@ -15,6 +15,7 @@ import { withDeployEnv, withFetchStub, } from "../../test-utils/deploy-test-support.ts"; +import { MISSING_API_CREDENTIAL_MESSAGE } from "../../shared/config.ts"; function toolDeployProject(controlPlane: InMemoryDeployControlPlane): DeployProject { return createDeployProject({ @@ -172,6 +173,30 @@ describe("mcp/tools/deploy-tool", () => { ); }); + it("reports the CLI's missing-credential error as not authenticated", async () => { + setHostSecret("VERYFRONT_API_TOKEN", "stored-login-token"); + try { + const result = await withDeployEnv( + () => + triggerDeploy( + { projectSlug: "my-app", environment: "production", branch: "main" }, + { deployProject: throwingDeployProject(new Error(MISSING_API_CREDENTIAL_MESSAGE)) }, + ), + { VERYFRONT_API_TOKEN: null }, + ); + + assertEquals(result.success, false); + if (result.success) throw new Error("unreachable"); + assertEquals( + result.error, + "Not authenticated. Run 'veryfront login' first.", + "the credential error keeps its classification when its wording changes", + ); + } finally { + deleteHostSecret("VERYFRONT_API_TOKEN"); + } + }); + it("passes the auth gate with a host-private stored login token", async () => { // A stored `veryfront login` token is registered host-privately instead // of being exported, so the environment snapshot never carries it. The diff --git a/cli/mcp/tools/deploy-tool.ts b/cli/mcp/tools/deploy-tool.ts index fc81a0c580..dfd2b6df95 100644 --- a/cli/mcp/tools/deploy-tool.ts +++ b/cli/mcp/tools/deploy-tool.ts @@ -14,6 +14,7 @@ import type { MCPTool } from "veryfront/mcp"; import { getEnvironmentConfig } from "veryfront/config"; import { cwd } from "veryfront/platform"; import { getHostEnv } from "#cli/process-env"; +import { MISSING_API_CREDENTIAL_MESSAGE } from "../../shared/config.ts"; import { createDeployProject, type DeployProject } from "../../shared/deployment/deploy-project.ts"; import type { DeployResult } from "../../shared/deployment/result.ts"; @@ -112,7 +113,7 @@ export async function triggerDeploy( : undefined; if ( - message.includes("Missing API token") || + message.includes(MISSING_API_CREDENTIAL_MESSAGE) || message.includes("Authentication required") || status === 401 ) { @@ -144,7 +145,7 @@ export const vfTriggerDeploy: MCPTool = "Check the returned urlVerification field to tell what the readiness probe established: " + "'served' means the app itself answered, 'gated' means only its access gate answered so the " + "app was never observed, and 'unprobed' means the project has no static page route to check. " + - "Requires a valid API token (set VERYFRONT_API_TOKEN or run 'veryfront login'). " + + "Requires a valid API key (set VERYFRONT_API_KEY or run 'veryfront login'). " + "Do not use for local builds; use vf_build instead. " + "Do not use for running tests before deploy; use vf_run_tests instead.", inputSchema: triggerDeployInput, diff --git a/cli/shared/config.test.ts b/cli/shared/config.test.ts index 617e2c8a5b..fce0288b46 100644 --- a/cli/shared/config.test.ts +++ b/cli/shared/config.test.ts @@ -5,13 +5,14 @@ import "#veryfront/schemas/_test-setup.ts"; */ import { assertEquals, assertInstanceOf, assertRejects } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; +import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import { assertApiUrlAcceptsNewCredential, createApiClient, ENVIRONMENT_PROJECT_REFERENCE_NAMES, isRetryableApiReadError, isUntrustedApiUrlCredentialError, + MISSING_API_CREDENTIAL_MESSAGE, readConfigFile, resolveApiCredentialCandidatesForAuth, resolveConfig, @@ -106,7 +107,7 @@ describe("resolveConfig", () => { await assertRejects( () => resolveConfig("/tmp/test-dir", env), Error, - "Missing API token", + MISSING_API_CREDENTIAL_MESSAGE, ); }); @@ -660,7 +661,7 @@ describe("resolveConfig", () => { assertInstanceOf(error, Error); assertEquals(error.message.includes(ciSecret), false); assertEquals(error.message.includes("attacker.example"), false); - assertEquals(error.message.includes("Add a literal VERYFRONT_API_TOKEN"), false); + assertEquals(error.message.includes("Add a literal VERYFRONT_API_KEY"), false); } finally { __resetEnvLoaderForTests(); await Deno.remove(tempDir, { recursive: true }); @@ -736,7 +737,12 @@ describe("resolveConfig", () => { ) { it(`keeps a token file ${label} away from the .env API URL`, async () => { const tempDir = await makeTempDir(); - const keys = ["VERYFRONT_API_URL", "VERYFRONT_API_TOKEN", "VERYFRONT_API_TOKEN_FILE"]; + const keys = [ + "VERYFRONT_API_URL", + "VERYFRONT_API_TOKEN", + "VERYFRONT_API_KEY", + "VERYFRONT_API_TOKEN_FILE", + ]; const originals = keys.map((key) => [key, Deno.env.get(key)] as const); const operatorSecret = "token_file_secret_do_not_forward"; const tokenFile = join(tempDir, "operator-token"); @@ -772,6 +778,115 @@ describe("resolveConfig", () => { } }); } + describe("VERYFRONT_API_KEY under the same repository trust rules", () => { + const saved: Record = {}; + const names = ["VERYFRONT_API_URL", "VERYFRONT_API_TOKEN", "VERYFRONT_API_KEY", "GITHUB_TOKEN"]; + let tempDir: string; + + beforeEach(async () => { + tempDir = await makeTempDir(); + for (const name of names) { + saved[name] = Deno.env.get(name); + Deno.env.delete(name); + } + __resetEnvLoaderForTests(); + }); + + afterEach(async () => { + __resetEnvLoaderForTests(); + await Deno.remove(tempDir, { recursive: true }); + for (const name of names) restoreEnv(name, saved[name]); + }); + + it("accepts a literal key written beside the API URL, like a literal token", async () => { + await Deno.writeTextFile( + join(tempDir, ".env"), + "VERYFRONT_API_URL=https://api.self-hosted.test\nVERYFRONT_API_KEY=\n", + ); + await loadEnv({ cwd: tempDir }); + assertEquals(Deno.env.get("VERYFRONT_API_TOKEN"), "", "the key fills the token"); + + const config = await resolveConfig( + tempDir, + createMockEnv({ + apiUrl: "https://api.self-hosted.test", + apiToken: "", + projectSlug: "test-project", + }), + ); + + assertEquals(config.apiUrl, "https://api.self-hosted.test"); + assertEquals(config.apiToken, "", "the repository supplied both URL and key"); + }); + + it("refuses a .env key that expands a shell secret for the steered endpoint", async () => { + const ciSecret = "ghs_shell_secret_do_not_forward"; + Deno.env.set("GITHUB_TOKEN", ciSecret); + await Deno.writeTextFile( + join(tempDir, ".env"), + "VERYFRONT_API_URL=https://attacker.example\nVERYFRONT_API_KEY=$GITHUB_TOKEN\n", + ); + await loadEnv({ cwd: tempDir }); + + const error = await assertRejects( + () => + resolveConfig( + tempDir, + createMockEnv({ + apiUrl: "https://attacker.example", + apiToken: ciSecret, + projectSlug: "test-project", + }), + ), + Error, + ); + + assertEquals(isUntrustedApiUrlCredentialError(error), true, "the expansion stays tainted"); + assertEquals( + (error as Error).message.includes(ciSecret), + false, + "the secret is never echoed", + ); + }); + + it("never sends a shell key to a repository-steered endpoint", async () => { + Deno.env.set("VERYFRONT_API_KEY", ""); + await Deno.writeTextFile( + join(tempDir, ".env"), + "VERYFRONT_API_URL=https://attacker.example\n", + ); + await loadEnv({ cwd: tempDir }); + + const error = await assertRejects( + () => + resolveConfig( + tempDir, + createMockEnv({ + apiUrl: "https://attacker.example", + apiToken: "", + projectSlug: "test-project", + }), + ), + Error, + ); + + assertEquals(isUntrustedApiUrlCredentialError(error), true, "a shell key is the developer's"); + assertEquals((error as Error).message.includes(""), false); + }); + + it("keeps an exported key on the default host when the repository steers nothing", async () => { + Deno.env.set("VERYFRONT_API_KEY", ""); + await loadEnv({ cwd: tempDir }); + + const config = await resolveConfig( + tempDir, + createMockEnv({ apiToken: "", projectSlug: "test-project" }), + ); + + assertEquals(config.apiToken, ""); + assertEquals(config.apiTokenSource, "env", "an exported key is a shell credential"); + }); + }); it("names the confirmation variable without echoing the configured endpoint", async () => { const tempDir = await makeTempDir(); @@ -1983,7 +2098,7 @@ describe("nonprompting management configuration", () => { createMockEnv({ xdgConfigHome: dir, projectSlug: "chosen" }), ), Error, - "Missing API token", + MISSING_API_CREDENTIAL_MESSAGE, ); }); }); diff --git a/cli/shared/config.ts b/cli/shared/config.ts index 0be8f9f937..9de48fbaef 100644 --- a/cli/shared/config.ts +++ b/cli/shared/config.ts @@ -10,7 +10,7 @@ import type { InferSchema } from "veryfront/extensions/schema"; import { basename, join } from "veryfront/platform/path"; import { createFileSystem, cwd, getEnv } from "veryfront/platform"; import { type EnvironmentConfig, getApiTokenEnv, getEnvironmentConfig } from "veryfront/config"; -import { getEnvSource } from "veryfront/utils/env-loader"; +import { getEnvSource, veryfrontApiCredentialEnvName } from "veryfront/utils/env-loader"; import { cliLogger, VERSION } from "#cli/utils"; import { readToken } from "../auth/token-store.ts"; import { ensureAuthenticated } from "../auth/login.ts"; @@ -398,7 +398,7 @@ function describeUntrustedApiUrl(trust: ApiUrlTrust): string { endpointSource.expandedFromProcessEnv; const repositoryCredential = trust.steeringEnvFile === undefined ? "Add a matching apiToken to the same veryfront.json" - : `Add a literal VERYFRONT_API_TOKEN to the same ${basename(trust.steeringEnvFile)} file`; + : `Add a literal VERYFRONT_API_KEY to the same ${basename(trust.steeringEnvFile)} file`; const confirmationKey = trust.steeringEnvKey ?? "VERYFRONT_API_URL"; const remedy = endpointExpandedFromProcess ? `Replace ${confirmationKey} with a literal endpoint in the same file, or, if you trust the ` @@ -450,6 +450,15 @@ function isRepositorySuppliedCredential( return !tokenSource.expandedFromProcessEnv; } +/** + * The error a management command throws when no credential is available. + * + * Exported so callers that classify it (such as the MCP deploy tool) match this + * constant instead of copying its wording. + */ +export const MISSING_API_CREDENTIAL_MESSAGE = + "Missing API key. Run 'veryfront login' or set VERYFRONT_API_KEY."; + function apiBaseUrlForRequest(apiUrl: string): string { const normalized = trimTrailingSlashes(apiUrl); if (normalized.endsWith("/graphql")) return normalized.slice(0, -8); @@ -732,9 +741,7 @@ async function resolveConfigBase( } if (!apiToken) { - throw new Error( - "Missing API token. Run 'veryfront login' or set VERYFRONT_API_TOKEN environment variable", - ); + throw new Error(MISSING_API_CREDENTIAL_MESSAGE); } let projectSlug: string | null | undefined; @@ -936,7 +943,7 @@ export function createApiClient( if (config.apiTokenSource !== "env-file") return message; if (status !== 401 && status !== 403 && status !== 404) return message; - return `${message}. VERYFRONT_API_TOKEN was loaded from a project .env file. For management commands, run 'veryfront login' and remove or rename the project runtime token, or pass a management token explicitly in the shell.`; + return `${message}. ${veryfrontApiCredentialEnvName()} was loaded from a project .env file. For management commands, run 'veryfront login' and remove or rename the project runtime token, or pass a management token explicitly in the shell.`; } async function requestOnce( diff --git a/cli/shared/deployment/deploy-project.ts b/cli/shared/deployment/deploy-project.ts index 9854f225ba..290f557756 100644 --- a/cli/shared/deployment/deploy-project.ts +++ b/cli/shared/deployment/deploy-project.ts @@ -78,6 +78,7 @@ import { type EnvironmentAccessTarget, } from "./control-plane.ts"; import type { DeployResult, LiveSourceResult } from "./result.ts"; +import { veryfrontApiCredentialEnvName } from "veryfront/utils/env-loader"; export interface DeployProjectRequest { projectDir: string; @@ -1710,7 +1711,7 @@ function getProbeCredentialRemedy( ): string { const again = retryInstruction(retry); if (source === "env") { - return `VERYFRONT_API_TOKEN is set in this shell and is resolved ahead of any stored session, so veryfront login does not change what the probe sends. Open the URL signed in, or unset that variable, run veryfront login, and ${again}, to confirm the app responds.`; + return `${veryfrontApiCredentialEnvName()} is set in this shell and is resolved ahead of any stored session, so veryfront login does not change what the probe sends. Open the URL signed in, or unset that variable, run veryfront login, and ${again}, to confirm the app responds.`; } if (source === "config-file") { return `The apiToken in veryfront.json is resolved ahead of any stored session, so veryfront login does not change what the probe sends. Open the URL signed in, or remove that token, run veryfront login, and ${again}, to confirm the app responds.`; diff --git a/cli/skills/core-skills.generated.ts b/cli/skills/core-skills.generated.ts index 2db45ead56..345a0bb607 100644 --- a/cli/skills/core-skills.generated.ts +++ b/cli/skills/core-skills.generated.ts @@ -117,7 +117,7 @@ export const CORE_SKILLS: LoadedSkill[] = [ "references/COMPONENTS.md": '# Veryfront Component Patterns\n\nBest practices for building React components in Veryfront applications.\n\n## Component Structure\n\n### File Organization\n\n```\ncomponents/\n├── ui/ # Primitive UI components\n│ ├── button.tsx\n│ ├── input.tsx\n│ └── card.tsx\n├── forms/ # Form components\n│ ├── login-form.tsx\n│ └── signup-form.tsx\n├── layouts/ # Layout components\n│ ├── header.tsx\n│ └── footer.tsx\n└── features/ # Feature-specific components\n ├── user-profile.tsx\n └── product-card.tsx\n```\n\n### Component File Structure\n\n```tsx\n// components/ui/button.tsx\n\n// 1. Imports\nimport { type ButtonHTMLAttributes, forwardRef } from "react";\nimport { cn } from "@/lib/utils";\n\n// 2. Types\nexport interface ButtonProps extends ButtonHTMLAttributes {\n variant?: "primary" | "secondary" | "ghost";\n size?: "sm" | "md" | "lg";\n loading?: boolean;\n}\n\n// 3. Component\nexport const Button = forwardRef(\n ({ className, variant = "primary", size = "md", loading, children, ...props }, ref) => {\n return (\n \n {loading ? : null}\n {children}\n \n );\n },\n);\n\nButton.displayName = "Button";\n```\n\n## Server vs Client Components\n\n### Server Components (Default)\n\n```tsx\n// app/users/page.tsx\n// No \'use client\' directive = Server Component\n\nasync function getUsers() {\n // Can access database directly\n const users = await db.users.findMany();\n return users;\n}\n\nexport default async function UsersPage() {\n const users = await getUsers();\n\n return (\n
\n

Users

\n \n
\n );\n}\n```\n\n**When to use:**\n\n- Data fetching\n- Accessing backend resources\n- Keeping sensitive data on server\n- Reducing client bundle size\n\n### Client Components\n\n```tsx\n// components/counter.tsx\n"use client";\n\nimport { useState } from "react";\n\nexport function Counter() {\n const [count, setCount] = useState(0);\n\n return (\n
\n

Count: {count}

\n \n
\n );\n}\n```\n\n**When to use:**\n\n- useState, useEffect, useContext\n- Event handlers (onClick, onChange)\n- Browser APIs (localStorage, window)\n- Interactive features\n\n### Composition Pattern\n\n```tsx\n// app/dashboard/page.tsx (Server Component)\nimport { DashboardStats } from "./dashboard-stats"; // Server\nimport { DashboardChart } from "./dashboard-chart"; // Client\n\nexport default async function Dashboard() {\n const stats = await getStats();\n\n return (\n
\n {/* Server component with data */}\n \n\n {/* Client component for interactivity */}\n \n
\n );\n}\n```\n\n## Props Patterns\n\n### Required vs Optional\n\n```tsx\ninterface CardProps {\n // Required\n title: string;\n children: React.ReactNode;\n\n // Optional with defaults\n variant?: "default" | "outlined";\n padding?: "none" | "sm" | "md" | "lg";\n}\n\nexport function Card({\n title,\n children,\n variant = "default",\n padding = "md",\n}: CardProps) {\n // ...\n}\n```\n\n### Compound Components\n\n```tsx\n// components/tabs.tsx\ninterface TabsContextValue {\n activeTab: string;\n setActiveTab: (tab: string) => void;\n}\n\nconst TabsContext = createContext(null);\n\nexport function Tabs({ children, defaultTab }: TabsProps) {\n const [activeTab, setActiveTab] = useState(defaultTab);\n\n return (\n \n
{children}
\n
\n );\n}\n\nexport function TabList({ children }: { children: React.ReactNode }) {\n return
{children}
;\n}\n\nexport function Tab({ value, children }: TabProps) {\n const { activeTab, setActiveTab } = useContext(TabsContext)!;\n\n return (\n setActiveTab(value)}\n >\n {children}\n \n );\n}\n\nexport function TabPanel({ value, children }: TabPanelProps) {\n const { activeTab } = useContext(TabsContext)!;\n if (activeTab !== value) return null;\n return
{children}
;\n}\n\n// Usage\n\n \n Overview\n Settings\n \n Overview content\n Settings content\n;\n```\n\n### Polymorphic Components\n\n```tsx\ntype PolymorphicProps = {\n as?: E;\n children: React.ReactNode;\n} & React.ComponentPropsWithoutRef;\n\nexport function Text({\n as,\n children,\n className,\n ...props\n}: PolymorphicProps) {\n const Component = as || \'p\';\n\n return (\n \n {children}\n \n );\n}\n\n// Usage\nParagraph text\nInline text\nHeading\n```\n\n## State Management\n\n### Local State\n\n```tsx\n"use client";\n\nimport { useState } from "react";\n\nexport function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {\n const [query, setQuery] = useState("");\n\n const handleSubmit = (e: React.FormEvent) => {\n e.preventDefault();\n onSearch(query);\n };\n\n return (\n
\n setQuery(e.target.value)}\n placeholder="Search..."\n />\n \n \n );\n}\n```\n\n### Derived State\n\n```tsx\n"use client";\n\nimport { useMemo } from "react";\n\nexport function FilteredList({ items, filter }: Props) {\n // Derive filtered list - don\'t store in state\n const filteredItems = useMemo(\n () => items.filter((item) => item.name.includes(filter)),\n [items, filter],\n );\n\n return (\n
    \n {filteredItems.map((item) =>
  • {item.name}
  • )}\n
\n );\n}\n```\n\n### URL State\n\n```tsx\n"use client";\n\nimport { useRouter, useSearchParams } from "next/navigation";\n\nexport function Filters() {\n const searchParams = useSearchParams();\n const router = useRouter();\n\n const category = searchParams.get("category") || "all";\n\n const setCategory = (newCategory: string) => {\n const params = new URLSearchParams(searchParams);\n params.set("category", newCategory);\n router.push(`?${params.toString()}`);\n };\n\n return (\n \n );\n}\n```\n\n## Data Fetching\n\n### Server Component Fetch\n\n```tsx\n// app/products/page.tsx\nasync function getProducts() {\n const res = await fetch("https://api.example.com/products", {\n next: { revalidate: 60 }, // Cache for 60 seconds\n });\n return res.json();\n}\n\nexport default async function ProductsPage() {\n const products = await getProducts();\n return ;\n}\n```\n\n### Client-side Fetch with SWR\n\n```tsx\n"use client";\n\nimport useSWR from "swr";\n\nconst fetcher = (url: string) => fetch(url).then((r) => r.json());\n\nexport function UserProfile({ userId }: { userId: string }) {\n const { data, error, isLoading } = useSWR(\n `/api/users/${userId}`,\n fetcher,\n );\n\n if (isLoading) return ;\n if (error) return ;\n\n return (\n
\n

{data.name}

\n

{data.email}

\n
\n );\n}\n```\n\n### Server Actions\n\n```tsx\n// app/actions.ts\n"use server";\n\nexport async function createPost(formData: FormData) {\n const title = formData.get("title") as string;\n const content = formData.get("content") as string;\n\n const post = await db.posts.create({ title, content });\n revalidatePath("/posts");\n\n return { success: true, id: post.id };\n}\n\n// components/post-form.tsx\n"use client";\n\nimport { createPost } from "../app/actions";\n\nexport function PostForm() {\n return (\n
\n \n