Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/cli/commands/auth/whoami.ts
Original file line number Diff line number Diff line change
@@ -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<RunCommandResult> {
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)}` };
}
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/cli/utils/command/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
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";

/**
* Check authentication status and trigger login flow if needed.
* Sets user context on the error reporter after successful auth.
*/
export async function ensureAuth(ctx: CLIContext): Promise<void> {
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();
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/core/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> | 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<void> {
const accessToken = process.env.BASE44_ACCESS_TOKEN;
if (!accessToken) {
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/core/clients/base44-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,6 +54,10 @@ async function handleUnauthorized(
return;
}

if (hasWorkspaceApiKeyAuth()) {
Comment thread
yoavf marked this conversation as resolved.
return;
}

// Prevent infinite retry loop - only retry once per request
if (retriedRequests.has(request)) {
return;
Expand Down Expand Up @@ -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();

Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/core/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 : [];

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.

Blocking: this breaks connector removal.

pushConnectors([]) was not a needless call — it's the reconcile half of deploy. syncOAuthConnectors removes remote connectors that are no longer in local config (connector/push.ts:83-99), and syncStripeConnector(undefined) removes Stripe when it's installed remotely but absent locally (stripe.ts:35). With this change, deleting your last connector from config and deploying leaves it connected remotely forever — silently. (Removing one-of-several still works since the list stays non-empty.)

The deploy.spec.ts change also locks the regression in: mocking the list endpoint as a 403 makes the test fail if the reconcile call ever comes back.

If the motivation is that workspace API keys get a 403 on the connectors-list endpoint, scope the skip to that case (hasWorkspaceApiKeyAuth() && connectors.length === 0) or fix the permission server-side — don't change deploy semantics for OAuth users.


if (project.site?.outputDirectory) {
const outputDir = resolve(project.root, project.site.outputDirectory);
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/tests/cli/deploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
72 changes: 72 additions & 0 deletions packages/cli/tests/cli/env-token-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading