diff --git a/docs/api-patterns.md b/docs/api-patterns.md index b87c9aea..26c88fc7 100644 --- a/docs/api-patterns.md +++ b/docs/api-patterns.md @@ -45,6 +45,24 @@ The `base44Client` automatically handles token refresh: ## Environment-Supplied Credentials +### Workspace API keys + +For machine-principal deploy flows, set `BASE44_API_KEY` to a workspace API key +(`b44k_...`). The shared `base44Client` sends this as the `api_key` header on +API requests and skips OAuth token refresh. This mode does not write +`~/.base44/auth/auth.json`; `base44 whoami` reports the key prefix instead. + +Example: + +```bash +BASE44_API_KEY=b44k_... BASE44_APP_ID= base44 deploy --yes +``` + +Use this for CI or other non-interactive deployers that should act as a +workspace-owned machine principal rather than a human user. + +### OAuth access/refresh tokens + For non-interactive flows (CI, agents, provisioning tools) that hand off an app's credentials via the environment, the `ensureAuth` middleware calls `seedAuthFromEnv()`: when `BASE44_ACCESS_TOKEN` is set, it decodes the JWT diff --git a/packages/cli/src/cli/commands/auth/whoami.ts b/packages/cli/src/cli/commands/auth/whoami.ts index 3bd0843d..53c487b6 100644 --- a/packages/cli/src/cli/commands/auth/whoami.ts +++ b/packages/cli/src/cli/commands/auth/whoami.ts @@ -1,9 +1,20 @@ import type { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, theme } from "@/cli/utils/index.js"; -import { readAuth } from "@/core/auth/index.js"; +import { + getWorkspaceApiKeyFromEnv, + isWorkspaceApiKey, + readAuth, +} from "@/core/auth/index.js"; async function whoami(_ctx: CLIContext): Promise { + const workspaceApiKey = getWorkspaceApiKeyFromEnv(); + if (workspaceApiKey && isWorkspaceApiKey(workspaceApiKey)) { + return { + outroMessage: `Using workspace API key: ${theme.styles.bold(workspaceApiKey.slice(0, 10))}`, + }; + } + const auth = await readAuth(); return { outroMessage: `Logged in as: ${theme.styles.bold(auth.email)}` }; } diff --git a/packages/cli/src/cli/utils/command/middleware.ts b/packages/cli/src/cli/utils/command/middleware.ts index fcfd12e8..742287f0 100644 --- a/packages/cli/src/cli/utils/command/middleware.ts +++ b/packages/cli/src/cli/utils/command/middleware.ts @@ -1,6 +1,11 @@ import { login } from "@/cli/commands/auth/login-flow.js"; import type { CLIContext } from "@/cli/types.js"; -import { isLoggedIn, readAuth, seedAuthFromEnv } from "@/core/auth/index.js"; +import { + hasWorkspaceApiKeyAuth, + isLoggedIn, + readAuth, + seedAuthFromEnv, +} from "@/core/auth/index.js"; import { initAppContext } from "@/core/project/index.js"; /** @@ -8,6 +13,13 @@ import { initAppContext } from "@/core/project/index.js"; * Sets user context on the error reporter after successful auth. */ export async function ensureAuth(ctx: CLIContext): Promise { + if (hasWorkspaceApiKeyAuth()) { + ctx.errorReporter.setContext({ + user: { email: "workspace-api-key", name: "Workspace API key" }, + }); + return; + } + // Seed auth.json from env-supplied credentials (CI, agents, provisioning // tools) before the login check, so env tokens satisfy auth without a login. await seedAuthFromEnv(); diff --git a/packages/cli/src/core/auth/config.ts b/packages/cli/src/core/auth/config.ts index 6c139024..d24bfc48 100644 --- a/packages/cli/src/core/auth/config.ts +++ b/packages/cli/src/core/auth/config.ts @@ -8,10 +8,25 @@ import { deleteFile, readJsonFile, writeJsonFile } from "@/core/utils/fs.js"; // Buffer time before expiration to trigger proactive refresh (60 seconds) const TOKEN_REFRESH_BUFFER_MS = 60 * 1000; +const WORKSPACE_API_KEY_PREFIX = "b44k_"; // Lock to prevent concurrent token refreshes let refreshPromise: Promise | null = null; +export function getWorkspaceApiKeyFromEnv(): string | null { + const key = process.env.BASE44_API_KEY?.trim(); + return key ? key : null; +} + +export function isWorkspaceApiKey(value: string): boolean { + return value.startsWith(WORKSPACE_API_KEY_PREFIX); +} + +export function hasWorkspaceApiKeyAuth(): boolean { + const key = getWorkspaceApiKeyFromEnv(); + return key !== null && isWorkspaceApiKey(key); +} + export async function seedAuthFromEnv(): Promise { const accessToken = process.env.BASE44_ACCESS_TOKEN; if (!accessToken) { diff --git a/packages/cli/src/core/clients/base44-client.ts b/packages/cli/src/core/clients/base44-client.ts index 7cbcb2e7..a50e52fa 100644 --- a/packages/cli/src/core/clients/base44-client.ts +++ b/packages/cli/src/core/clients/base44-client.ts @@ -7,7 +7,10 @@ import { randomUUID } from "node:crypto"; import type { KyRequest, KyResponse, NormalizedOptions } from "ky"; import ky from "ky"; import { + getWorkspaceApiKeyFromEnv, + hasWorkspaceApiKeyAuth, isTokenExpired, + isWorkspaceApiKey, readAuth, refreshAndSaveTokens, } from "@/core/auth/config.js"; @@ -51,6 +54,10 @@ async function handleUnauthorized( return; } + if (hasWorkspaceApiKeyAuth()) { + return; + } + // Prevent infinite retry loop - only retry once per request if (retriedRequests.has(request)) { return; @@ -97,6 +104,12 @@ export const base44Client = ky.create({ }, captureRequestBody, async (request) => { + const workspaceApiKey = getWorkspaceApiKeyFromEnv(); + if (workspaceApiKey && isWorkspaceApiKey(workspaceApiKey)) { + request.headers.set("api_key", workspaceApiKey); + return; + } + try { const auth = await readAuth(); diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index 5a358842..0751e6a7 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -16,6 +16,12 @@ import { ApiErrorResponseSchema, } from "@/core/clients/schemas.js"; +// Inlined rather than imported from core/auth/config to avoid a circular +// import (auth/config depends on this module for its error classes). +function usingWorkspaceApiKey(): boolean { + return process.env.BASE44_API_KEY?.trim().startsWith("b44k_") ?? false; +} + // ============================================================================ // API Error Response Parsing // ============================================================================ @@ -393,6 +399,14 @@ export class ApiError extends SystemError { ]; } if (statusCode === 401) { + if (usingWorkspaceApiKey()) { + return [ + { + message: + "The workspace API key (BASE44_API_KEY) was rejected. Verify it is valid and authorized for this app.", + }, + ]; + } return [{ message: "Try logging in again", command: "base44 login" }]; } if (statusCode === 403) { diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 68089173..21124716 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -79,7 +79,8 @@ export async function deployAll( }); await agentResource.push(agents); await authConfigResource.push(authConfig); - const { results: connectorResults } = await pushConnectors(connectors); + const connectorResults = + connectors.length > 0 ? (await pushConnectors(connectors)).results : []; if (project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index 06192555..c765e2bd 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -82,8 +82,14 @@ describe("deploy command (unified)", () => { t.api.mockEntitiesPush({ created: ["Order"], updated: [], deleted: [] }); t.api.mockSingleFunctionDeploy({ status: "deployed" }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); - t.api.mockConnectorsList({ integrations: [] }); - t.api.mockStripeStatus({ stripe_mode: null }); + t.api.mockConnectorsListError({ + status: 403, + body: { + error: "Forbidden", + detail: + "Connectors should not be listed when no connectors are deployed", + }, + }); const result = await t.run("deploy", "-y"); diff --git a/packages/cli/tests/cli/env-token-auth.spec.ts b/packages/cli/tests/cli/env-token-auth.spec.ts index 5b4e3890..e7cecde4 100644 --- a/packages/cli/tests/cli/env-token-auth.spec.ts +++ b/packages/cli/tests/cli/env-token-auth.spec.ts @@ -73,6 +73,78 @@ describe("env credential seeding", () => { expect(authHeader).toBe(`Bearer ${jwt}`); }); + it("shows workspace API key auth in whoami without a stored login", async () => { + t.givenEnv({ + BASE44_API_KEY: + "b44k_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + + const result = await t.run("whoami"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Using workspace API key: b44k_aaaaa"); + expect(await t.readAuthFile()).toBeNull(); + }); + + it("uses BASE44_API_KEY as the api_key header for API calls", async () => { + await t.givenProject(fixture("with-entities")); + const workspaceApiKey = + "b44k_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + t.givenEnv({ BASE44_API_KEY: workspaceApiKey }); + + let apiKeyHeader: string | undefined; + let authHeader: string | undefined; + t.api.mockRoute("PUT", `/api/apps/${APP_ID}/entity-schemas`, (req, res) => { + apiKeyHeader = req.headers.api_key as string | undefined; + authHeader = req.headers.authorization; + res.status(200).json({ created: ["customer"], updated: [], deleted: [] }); + }); + + const result = await t.run("entities", "push"); + + t.expectResult(result).toSucceed(); + expect(apiKeyHeader).toBe(workspaceApiKey); + expect(authHeader).toBeUndefined(); + expect(await t.readAuthFile()).toBeNull(); + }); + + it("gives a workspace-key hint (not 'base44 login') on a rejected key", async () => { + await t.givenProject(fixture("with-entities")); + t.givenEnv({ + BASE44_API_KEY: + "b44k_cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + }); + t.api.mockEntitiesPushError({ + status: 401, + body: { error: "Unauthorized", detail: "Invalid API key" }, + }); + + const result = await t.run("entities", "push"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("workspace API key"); + t.expectResult(result).toNotContain("base44 login"); + }); + + it("ignores a non-workspace BASE44_API_KEY when OAuth auth exists", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.givenEnv({ BASE44_API_KEY: "not-a-workspace-key" }); + + let apiKeyHeader: string | undefined; + let authHeader: string | undefined; + t.api.mockRoute("PUT", `/api/apps/${APP_ID}/entity-schemas`, (req, res) => { + apiKeyHeader = req.headers.api_key as string | undefined; + authHeader = req.headers.authorization; + res.status(200).json({ created: ["customer"], updated: [], deleted: [] }); + }); + + const result = await t.run("entities", "push"); + + t.expectResult(result).toSucceed(); + expect(apiKeyHeader).toBeUndefined(); + expect(authHeader).toBe("Bearer test-access-token"); + }); + it("does not overwrite a stored login when env credentials are incomplete", async () => { await t.givenLoggedIn({ email: "real@example.com", name: "Real User" }); // Access token present but no refresh token → can't form a standard record,